From a2fc0d6c7df8ccb184372629480149e301b046b6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:11:12 -0400 Subject: [PATCH] docs: add Android news screen implementation plan --- .../plans/2026-04-06-android-news-screen.md | 823 ++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-android-news-screen.md diff --git a/docs/superpowers/plans/2026-04-06-android-news-screen.md b/docs/superpowers/plans/2026-04-06-android-news-screen.md new file mode 100644 index 0000000..b5e43c1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-android-news-screen.md @@ -0,0 +1,823 @@ +# 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" +```