From 23509adfa82b48fa6d0dec3408fe92a8d01e434f Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Fri, 27 Mar 2026 00:08:51 -0400 Subject: [PATCH] feat: news story cards in Android briefing screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces bare Story-N reaction rows with full NewsCard widgets: source label, relative timestamp, linked headline, 2-line snippet, and 👍/👎 reactions. Reads rss_items from message metadata (requires backend ≥ this sprint). Adds url_launcher ^6.3.1 for opening article links in the browser. Adds https/http entries to AndroidManifest for Android 11+. Co-Authored-By: Claude Sonnet 4.6 --- android/app/src/main/AndroidManifest.xml | 9 ++ lib/screens/briefing/briefing_screen.dart | 88 ++--------- lib/widgets/news_card.dart | 184 ++++++++++++++++++++++ pubspec.yaml | 1 + 4 files changed, 208 insertions(+), 74 deletions(-) create mode 100644 lib/widgets/news_card.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ba1763a..196122d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -70,5 +70,14 @@ + + + + + + + + + diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index dd36df0..36bcf3b 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -9,6 +9,7 @@ import '../../providers/briefing_provider.dart'; import '../../providers/api_client_provider.dart'; import '../../widgets/chat_message_bubble.dart'; import '../../widgets/weather_card.dart'; +import '../../widgets/news_card.dart'; import 'briefing_history_screen.dart'; class BriefingScreen extends ConsumerStatefulWidget { @@ -322,55 +323,27 @@ class _BriefingMessageItem extends StatelessWidget { final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); final weatherData = hasWeatherKey ? meta['weather'] as Map? : null; - // RSS reactions - final rssItemIds = isAssistant && meta != null - ? (meta['rss_item_ids'] as List?)?.cast() ?? [] - : []; - - final scheme = Theme.of(context).colorScheme; + // RSS news cards + final rssItemsRaw = isAssistant && meta != null + ? (meta['rss_items'] as List?)?.cast>() ?? [] + : >[]; + final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (hasWeatherKey) WeatherCard(weather: weatherData), ChatMessageBubble(message: message), - if (rssItemIds.isNotEmpty) + if (rssItems.isNotEmpty) Padding( - padding: const EdgeInsets.only(left: 8, bottom: 4), + padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: rssItemIds.asMap().entries.map((entry) { - final index = entry.key; - final itemId = entry.value; - final current = reactions[itemId]; - return Padding( - padding: const EdgeInsets.only(top: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Story ${index + 1}', - style: TextStyle( - fontSize: 12, - color: scheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 8), - _ReactionButton( - emoji: '👍', - active: current == 'up', - onTap: () => onReaction(itemId, 'up'), - ), - const SizedBox(width: 4), - _ReactionButton( - emoji: '👎', - active: current == 'down', - onTap: () => onReaction(itemId, 'down'), - ), - ], - ), - ); - }).toList(), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: rssItems.map((item) => NewsCard( + item: item, + reaction: reactions[item.id], + onReaction: onReaction, + )).toList(), ), ), ], @@ -378,39 +351,6 @@ class _BriefingMessageItem extends StatelessWidget { } } -class _ReactionButton extends StatelessWidget { - final String emoji; - final bool active; - final VoidCallback onTap; - - const _ReactionButton({ - required this.emoji, - required this.active, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: active - ? scheme.primary.withValues(alpha: 0.12) - : Colors.transparent, - border: Border.all( - color: active ? scheme.primary : scheme.outlineVariant, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Text(emoji, style: const TextStyle(fontSize: 14)), - ), - ); - } -} class _GradientSendButton extends StatelessWidget { final VoidCallback? onPressed; diff --git a/lib/widgets/news_card.dart b/lib/widgets/news_card.dart new file mode 100644 index 0000000..c048f76 --- /dev/null +++ b/lib/widgets/news_card.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class RssItemMeta { + final int id; + final String title; + final String url; + final String source; + final String snippet; + final DateTime? publishedAt; + + const RssItemMeta({ + required this.id, + required this.title, + required this.url, + required this.source, + required this.snippet, + this.publishedAt, + }); + + factory RssItemMeta.fromJson(Map json) => RssItemMeta( + id: json['id'] as int, + title: json['title'] as String? ?? '', + url: json['url'] as String? ?? '', + source: json['source'] as String? ?? '', + snippet: json['snippet'] as String? ?? '', + publishedAt: json['published_at'] != null + ? DateTime.tryParse(json['published_at'] as String) + : null, + ); + + String get relativeDate { + if (publishedAt == null) return ''; + final diff = DateTime.now().difference(publishedAt!); + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inHours < 48) return 'Yesterday'; + return '${publishedAt!.month}/${publishedAt!.day}'; + } +} + +class NewsCard extends StatelessWidget { + final RssItemMeta item; + final String? reaction; // 'up' | 'down' | null + final void Function(int itemId, String reaction) onReaction; + + const NewsCard({ + super.key, + required this.item, + required this.reaction, + required this.onReaction, + }); + + Future _openUrl() async { + if (item.url.isEmpty) return; + final uri = Uri.tryParse(item.url); + if (uri != null && await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: scheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Source + date row + Row( + children: [ + if (item.source.isNotEmpty) + Text( + item.source.toUpperCase(), + style: textTheme.labelSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ), + ), + if (item.source.isNotEmpty && item.relativeDate.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + item.relativeDate, + style: textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + // Title — tappable if URL present + GestureDetector( + onTap: item.url.isNotEmpty ? _openUrl : null, + child: Text( + item.title, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: item.url.isNotEmpty ? scheme.primary : scheme.onSurface, + decoration: item.url.isNotEmpty ? TextDecoration.underline : null, + decorationColor: scheme.primary, + ), + ), + ), + // Snippet + if (item.snippet.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + item.snippet, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + height: 1.45, + ), + ), + ], + const SizedBox(height: 8), + // Reaction buttons + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ReactionButton( + emoji: '👍', + active: reaction == 'up', + onTap: () => onReaction(item.id, 'up'), + ), + const SizedBox(width: 6), + _ReactionButton( + emoji: '👎', + active: reaction == 'down', + onTap: () => onReaction(item.id, 'down'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _ReactionButton extends StatelessWidget { + final String emoji; + final bool active; + final VoidCallback onTap; + + const _ReactionButton({ + required this.emoji, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: active ? scheme.primary.withValues(alpha: 0.12) : Colors.transparent, + border: Border.all( + color: active ? scheme.primary : scheme.outlineVariant, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text(emoji, style: const TextStyle(fontSize: 14)), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 50c50e3..a5371bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: flutter_markdown_plus: ^1.0.7 google_fonts: ^8.0.2 flutter_timezone: ^5.0.2 + url_launcher: ^6.3.1 dev_dependencies: flutter_test: