# Android News Screen Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the Android News screen — a paginated, feed-filtered list of RSS news items with reactions and a Discuss action that opens a new general chat conversation. **Architecture:** `NewsNotifier` (`AsyncNotifier`) holds the accumulated item list, pagination, reaction map, and selected feed. The screen is a `ConsumerStatefulWidget` at `/news`. Discuss calls `POST /api/chat/from-article/{id}` (same endpoint as web) and navigates to the returned conversation. Reactions reuse `briefingApiProvider`. The existing `NewsCard` widget is used unchanged via a `RssItemMeta.fromNewsItem` adapter factory. **Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget --- ## Files | File | Action | Responsibility | |------|--------|---------------| | `lib/data/models/news_item.dart` | Create | `NewsItem` model + `fromJson` | | `lib/data/models/briefing_feed.dart` | Create | `BriefingFeed` model + `fromJson` | | `lib/data/api/news_api.dart` | Create | `getNewsItems(...)` + `getFeeds()` Dio calls | | `lib/data/api/chat_api.dart` | Modify | Add `openArticleInChat(int itemId)` | | `lib/providers/api_client_provider.dart` | Modify | Add `newsApiProvider` | | `lib/widgets/news_card.dart` | Modify | Add `RssItemMeta.fromNewsItem` factory | | `lib/providers/news_provider.dart` | Create | `NewsState`, `NewsNotifier`, `newsProvider`, `feedsProvider` | | `lib/screens/news/news_screen.dart` | Create | News screen UI | | `lib/app.dart` | Modify | Replace News stub route with `NewsScreen()` | | `test/widget_test.dart` | Modify | Add `NewsItem.fromJson` and `BriefingFeed.fromJson` tests | --- ### Task 1: Data models + tests **Files:** - Create: `lib/data/models/news_item.dart` - Create: `lib/data/models/briefing_feed.dart` - Modify: `test/widget_test.dart` - [ ] **Step 1: Create `NewsItem` model** Create `lib/data/models/news_item.dart`: ```dart class NewsItem { final int id; final String title; final String url; final String snippet; final String source; final DateTime? publishedAt; final List topics; final String? reaction; const NewsItem({ required this.id, required this.title, required this.url, required this.snippet, required this.source, this.publishedAt, required this.topics, this.reaction, }); factory NewsItem.fromJson(Map json) => NewsItem( id: json['id'] as int, title: json['title'] as String? ?? '', url: json['url'] as String? ?? '', snippet: json['snippet'] as String? ?? '', source: json['source'] as String? ?? '', publishedAt: json['published_at'] != null ? DateTime.tryParse(json['published_at'] as String) : null, topics: (json['topics'] as List?) ?.cast() .toList() ?? [], reaction: json['reaction'] as String?, ); } ``` - [ ] **Step 2: Create `BriefingFeed` model** Create `lib/data/models/briefing_feed.dart`: ```dart class BriefingFeed { final int id; final String title; final String url; final String? category; const BriefingFeed({ required this.id, required this.title, required this.url, this.category, }); factory BriefingFeed.fromJson(Map json) => BriefingFeed( id: json['id'] as int, title: json['title'] as String? ?? '', url: json['url'] as String? ?? '', category: json['category'] as String?, ); } ``` - [ ] **Step 3: Write model tests** Add these groups to `test/widget_test.dart` (before the closing `}`): ```dart group('NewsItem.fromJson', () { test('parses all fields', () { final json = { 'id': 42, 'title': 'Big news', 'url': 'https://example.com/article', 'snippet': 'A short summary.', 'source': 'Example News', 'published_at': '2026-01-15T10:00:00', 'topics': ['tech', 'ai'], 'reaction': 'up', }; final item = NewsItem.fromJson(json); expect(item.id, equals(42)); expect(item.title, equals('Big news')); expect(item.url, equals('https://example.com/article')); expect(item.snippet, equals('A short summary.')); expect(item.source, equals('Example News')); expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00'))); expect(item.topics, equals(['tech', 'ai'])); expect(item.reaction, equals('up')); }); test('handles null published_at and reaction', () { final json = { 'id': 1, 'title': '', 'url': '', 'snippet': '', 'source': '', 'published_at': null, 'topics': [], 'reaction': null, }; final item = NewsItem.fromJson(json); expect(item.publishedAt, isNull); expect(item.reaction, isNull); expect(item.topics, isEmpty); }); }); group('BriefingFeed.fromJson', () { test('parses all fields', () { final json = { 'id': 7, 'title': 'Hacker News', 'url': 'https://news.ycombinator.com/rss', 'category': 'tech', }; final feed = BriefingFeed.fromJson(json); expect(feed.id, equals(7)); expect(feed.title, equals('Hacker News')); expect(feed.url, equals('https://news.ycombinator.com/rss')); expect(feed.category, equals('tech')); }); test('handles null category', () { final json = { 'id': 8, 'title': 'Feed', 'url': 'https://example.com/rss', 'category': null, }; final feed = BriefingFeed.fromJson(json); expect(feed.category, isNull); }); }); ``` Also add these imports at the top of `test/widget_test.dart`: ```dart import 'package:fabled_app/data/models/news_item.dart'; import 'package:fabled_app/data/models/briefing_feed.dart'; ``` - [ ] **Step 4: Run tests** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter test ``` Expected: All tests passed (17 total — 15 existing + 2 new groups = 4 new tests). - [ ] **Step 5: Commit** ```bash git add lib/data/models/news_item.dart lib/data/models/briefing_feed.dart test/widget_test.dart git commit -m "feat: add NewsItem and BriefingFeed models with tests" ``` --- ### Task 2: API layer **Files:** - Create: `lib/data/api/news_api.dart` - Modify: `lib/data/api/chat_api.dart` - Modify: `lib/providers/api_client_provider.dart` - [ ] **Step 1: Create `NewsApi`** Create `lib/data/api/news_api.dart`: ```dart import 'package:dio/dio.dart'; import '../models/briefing_feed.dart'; import '../models/news_item.dart'; import 'api_client.dart'; class NewsApi { final Dio _dio; const NewsApi(this._dio); /// GET /api/briefing/news /// Returns up to [limit] items starting at [offset], optionally filtered by [feedId]. Future> getNewsItems({ int days = 90, int limit = 40, int offset = 0, int? feedId, }) async { try { final params = { 'days': days, 'limit': limit, 'offset': offset, if (feedId != null) 'feed_id': feedId, }; final response = await _dio.get( '/api/briefing/news', queryParameters: params, ); final data = response.data as Map; final list = data['items'] as List; return list .map((e) => NewsItem.fromJson(e as Map)) .toList(); } on DioException catch (e) { throw dioToApp(e); } } /// GET /api/briefing/feeds Future> getFeeds() async { try { final response = await _dio.get('/api/briefing/feeds'); final list = response.data as List; return list .map((e) => BriefingFeed.fromJson(e as Map)) .toList(); } on DioException catch (e) { throw dioToApp(e); } } } ``` - [ ] **Step 2: Add `openArticleInChat` to `ChatApi`** In `lib/data/api/chat_api.dart`, add this method at the end of the `ChatApi` class (before the closing `}`): ```dart /// POST /api/chat/from-article/{itemId} /// Creates or retrieves a chat conversation seeded with the article. /// Returns the conversation_id. Future openArticleInChat(int itemId) async { try { final response = await _dio.post('/api/chat/from-article/$itemId', data: {}); final data = response.data as Map; return data['conversation_id'] as int; } on DioException catch (e) { throw dioToApp(e); } } ``` - [ ] **Step 3: Add `newsApiProvider` to `api_client_provider.dart`** In `lib/providers/api_client_provider.dart`, add the import and provider. Add import after the existing API imports (e.g., after `voice_api.dart`): ```dart import '../data/api/news_api.dart'; ``` Add provider after `voiceRepositoryProvider`: ```dart final newsApiProvider = Provider((ref) { return NewsApi(ref.watch(dioProvider)); }); ``` - [ ] **Step 4: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart ``` Expected: `No issues found!` - [ ] **Step 5: Commit** ```bash git add lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart git commit -m "feat: add NewsApi, openArticleInChat, and newsApiProvider" ``` --- ### Task 3: RssItemMeta adapter **Files:** - Modify: `lib/widgets/news_card.dart` `NewsCard` renders `RssItemMeta` objects. Rather than modifying the widget, we add a factory on `RssItemMeta` that converts a `NewsItem`. - [ ] **Step 1: Add `fromNewsItem` factory** In `lib/widgets/news_card.dart`, add this import at the top: ```dart import '../data/models/news_item.dart'; ``` Then add this factory inside the `RssItemMeta` class, after the existing `fromJson` factory: ```dart factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta( id: item.id, title: item.title, url: item.url, source: item.source, snippet: item.snippet, publishedAt: item.publishedAt, ); ``` - [ ] **Step 2: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/widgets/news_card.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/widgets/news_card.dart git commit -m "feat: add RssItemMeta.fromNewsItem adapter factory" ``` --- ### Task 4: Providers **Files:** - Create: `lib/providers/news_provider.dart` - [ ] **Step 1: Create `news_provider.dart`** Create `lib/providers/news_provider.dart`: ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/briefing_feed.dart'; import '../data/models/news_item.dart'; import 'api_client_provider.dart'; // ─── NewsState ──────────────────────────────────────────────────────────────── class NewsState { final List items; final int offset; final bool hasMore; final bool loadingMore; final int? selectedFeedId; final Map reactions; const NewsState({ required this.items, required this.offset, required this.hasMore, required this.loadingMore, required this.selectedFeedId, required this.reactions, }); NewsState copyWith({ List? items, int? offset, bool? hasMore, bool? loadingMore, Object? selectedFeedId = _sentinel, Map? reactions, }) { return NewsState( items: items ?? this.items, offset: offset ?? this.offset, hasMore: hasMore ?? this.hasMore, loadingMore: loadingMore ?? this.loadingMore, selectedFeedId: selectedFeedId == _sentinel ? this.selectedFeedId : selectedFeedId as int?, reactions: reactions ?? this.reactions, ); } } const _sentinel = Object(); // ─── NewsNotifier ───────────────────────────────────────────────────────────── final newsProvider = AsyncNotifierProvider(NewsNotifier.new); class NewsNotifier extends AsyncNotifier { static const _limit = 40; @override Future build() async { final items = await ref.read(newsApiProvider).getNewsItems( days: 90, limit: _limit, offset: 0, ); return NewsState( items: items, offset: items.length, hasMore: items.length == _limit, loadingMore: false, selectedFeedId: null, reactions: {for (final item in items) item.id: item.reaction}, ); } Future loadMore() async { final current = state.value; if (current == null || current.loadingMore || !current.hasMore) return; state = AsyncData(current.copyWith(loadingMore: true)); try { final items = await ref.read(newsApiProvider).getNewsItems( days: 90, limit: _limit, offset: current.offset, feedId: current.selectedFeedId, ); final updatedReactions = Map.from(current.reactions); for (final item in items) { updatedReactions.putIfAbsent(item.id, () => item.reaction); } state = AsyncData(current.copyWith( items: [...current.items, ...items], offset: current.offset + items.length, hasMore: items.length == _limit, loadingMore: false, reactions: updatedReactions, )); } catch (e) { state = AsyncData(current.copyWith(loadingMore: false)); rethrow; } } Future setFeed(int? feedId) async { state = const AsyncLoading(); try { final items = await ref.read(newsApiProvider).getNewsItems( days: 90, limit: _limit, offset: 0, feedId: feedId, ); state = AsyncData(NewsState( items: items, offset: items.length, hasMore: items.length == _limit, loadingMore: false, selectedFeedId: feedId, reactions: {for (final item in items) item.id: item.reaction}, )); } catch (e, st) { state = AsyncError(e, st); } } void toggleReaction(int itemId, String reaction) { final current = state.value; if (current == null) return; final prev = current.reactions[itemId]; final next = prev == reaction ? null : reaction; state = AsyncData(current.copyWith( reactions: {...current.reactions, itemId: next}, )); final briefingApi = ref.read(briefingApiProvider); final future = next == null ? briefingApi.deleteRssReaction(itemId) : briefingApi.postRssReaction(itemId, next); future.catchError((_) { final s = state.value; if (s != null) { state = AsyncData(s.copyWith( reactions: {...s.reactions, itemId: prev}, )); } }); } } // ─── FeedsNotifier ──────────────────────────────────────────────────────────── final feedsProvider = AsyncNotifierProvider>(FeedsNotifier.new); class FeedsNotifier extends AsyncNotifier> { @override Future> build() async { return ref.read(newsApiProvider).getFeeds(); } } ``` - [ ] **Step 2: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/providers/news_provider.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/providers/news_provider.dart git commit -m "feat: add NewsNotifier and feedsProvider" ``` --- ### Task 5: News screen **Files:** - Create: `lib/screens/news/news_screen.dart` - [ ] **Step 1: Create the screen** Create `lib/screens/news/news_screen.dart`: ```dart 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: (_, __) => 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), ), ], ), ); } } ``` - [ ] **Step 2: Verify** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze lib/screens/news/news_screen.dart ``` Expected: `No issues found!` - [ ] **Step 3: Commit** ```bash git add lib/screens/news/news_screen.dart git commit -m "feat: add NewsScreen with feed filter, reactions, and discuss" ``` --- ### Task 6: Wire route and final verification **Files:** - Modify: `lib/app.dart` - [ ] **Step 1: Replace the News stub route with `NewsScreen`** In `lib/app.dart`, add the import near the other screen imports: ```dart import 'screens/news/news_screen.dart'; ``` Find and replace the stub route: ```dart GoRoute( path: Routes.news, builder: (_, _) => Scaffold( appBar: AppBar(title: const Text('News')), body: const Center(child: Text('News — coming soon')), ), ), ``` Replace with: ```dart GoRoute( path: Routes.news, builder: (_, _) => const NewsScreen(), ), ``` - [ ] **Step 2: Full analyze and tests** ```bash cd /home/bvandeusen/Nextcloud/Projects/fabled_app flutter analyze flutter test ``` Expected: `No issues found!` and all tests passed. - [ ] **Step 3: Commit** ```bash git add lib/app.dart git commit -m "feat: wire News route to NewsScreen" ```