From e7d174cef79adafed6e6c8923515d7c9075215d1 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:29:30 -0400 Subject: [PATCH] feat: add NewsScreen with feed filter, reactions, and discuss --- lib/screens/news/news_screen.dart | 184 ++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 lib/screens/news/news_screen.dart diff --git a/lib/screens/news/news_screen.dart b/lib/screens/news/news_screen.dart new file mode 100644 index 0000000..3094581 --- /dev/null +++ b/lib/screens/news/news_screen.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/constants.dart'; +import '../../data/models/briefing_feed.dart'; +import '../../providers/api_client_provider.dart'; +import '../../providers/news_provider.dart'; +import '../../widgets/news_card.dart'; + +class NewsScreen extends ConsumerStatefulWidget { + const NewsScreen({super.key}); + + @override + ConsumerState createState() => _NewsScreenState(); +} + +class _NewsScreenState extends ConsumerState { + final Set _openingChat = {}; + + Future _handleDiscuss(int itemId) async { + if (_openingChat.contains(itemId)) return; + setState(() => _openingChat.add(itemId)); + try { + final conversationId = + await ref.read(chatApiProvider).openArticleInChat(itemId); + if (mounted) { + context.push(Routes.chat.replaceFirst(':id', '$conversationId')); + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to open article in chat.')), + ); + } + } finally { + if (mounted) setState(() => _openingChat.remove(itemId)); + } + } + + Future _loadMore() async { + try { + await ref.read(newsProvider.notifier).loadMore(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to load more articles.')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final newsAsync = ref.watch(newsProvider); + final feedsAsync = ref.watch(feedsProvider); + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('News', style: Theme.of(context).textTheme.titleLarge), + Text( + 'Last 90 days', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + body: newsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Could not load news.'), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: () => ref.invalidate(newsProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (news) => Column( + children: [ + _FeedFilter( + feeds: feedsAsync.value ?? [], + selectedFeedId: news.selectedFeedId, + onChanged: (feedId) => + ref.read(newsProvider.notifier).setFeed(feedId), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + itemCount: news.items.length + 1, + itemBuilder: (_, i) { + if (i == news.items.length) { + if (!news.hasMore) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Center( + child: news.loadingMore + ? const CircularProgressIndicator() + : FilledButton.tonal( + onPressed: _loadMore, + child: const Text('Load more'), + ), + ), + ); + } + final item = news.items[i]; + return NewsCard( + item: RssItemMeta.fromNewsItem(item), + reaction: news.reactions[item.id], + onReaction: (itemId, reaction) => ref + .read(newsProvider.notifier) + .toggleReaction(itemId, reaction), + onDiscuss: _openingChat.contains(item.id) + ? null + : () => _handleDiscuss(item.id), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _FeedFilter extends StatelessWidget { + final List feeds; + final int? selectedFeedId; + final void Function(int? feedId) onChanged; + + const _FeedFilter({ + required this.feeds, + required this.selectedFeedId, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), + child: Row( + children: [ + Text( + 'Feed:', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: selectedFeedId, + underline: const SizedBox.shrink(), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All feeds'), + ), + ...feeds.map( + (f) => DropdownMenuItem( + value: f.id, + child: Text(f.title), + ), + ), + ], + onChanged: (v) => onChanged(v), + ), + ], + ), + ); + } +}