6.0 KiB
Android News Screen Design
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, filterable list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
Architecture: A NewsNotifier (AsyncNotifier) holds the full accumulated item list, pagination state, and selected feed ID. The screen is a ConsumerStatefulWidget accessed via the More bottom sheet at /news. Discuss uses POST /api/chat/from-article/{id} (same endpoint as the web) so any backend behaviour change is automatically reflected. Reactions reuse the existing briefingApiProvider methods. The existing NewsCard widget is used without modification.
Tech Stack: Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing NewsCard widget
Files
New
| File | Responsibility |
|---|---|
lib/data/models/news_item.dart |
NewsItem model + fromJson |
lib/data/models/briefing_feed.dart |
BriefingFeed model + fromJson |
lib/data/api/news_api.dart |
getNewsItems(...) and getFeeds() API calls |
lib/providers/news_provider.dart |
NewsNotifier, NewsState, newsProvider, feedsProvider |
lib/screens/news/news_screen.dart |
News screen UI |
Modified
| File | Change |
|---|---|
lib/data/api/chat_api.dart |
Add openArticleInChat(int itemId) → Future<int> |
lib/providers/api_client_provider.dart |
Add newsApiProvider |
Data Models
NewsItem
class NewsItem {
final int id;
final String title;
final String url;
final String snippet;
final String source;
final DateTime? publishedAt;
final List<String> topics;
final String? reaction; // 'up' | 'down' | null
}
fromJson maps: id, title, url, snippet, source, published_at (nullable ISO string → DateTime.tryParse), topics (cast List<dynamic> → List<String>), reaction (nullable string).
BriefingFeed
class BriefingFeed {
final int id;
final String title;
final String url;
final String? category;
}
API Layer
news_api.dart
class NewsApi {
final Dio _dio;
const NewsApi(this._dio);
// GET /api/briefing/news
Future<NewsItemsResponse> getNewsItems({
int days = 90,
int limit = 40,
int offset = 0,
int? feedId,
}) async { ... }
// GET /api/briefing/feeds
Future<List<BriefingFeed>> getFeeds() async { ... }
}
class NewsItemsResponse {
final List<NewsItem> items;
final int offset;
final int limit;
}
chat_api.dart addition
// POST /api/chat/from-article/{itemId}
// Returns conversation_id
Future<int> openArticleInChat(int itemId) async { ... }
api_client_provider.dart addition
final newsApiProvider = Provider<NewsApi>((ref) =>
NewsApi(ref.watch(dioProvider)));
State Management
NewsState
class NewsState {
final List<NewsItem> items;
final int offset;
final bool hasMore;
final bool loadingMore;
final int? selectedFeedId;
final Map<int, String?> reactions; // item id → 'up'|'down'|null
}
NewsNotifier extends AsyncNotifier<NewsState>
build(): fetches first page (offset=0, no feed filter); initialisesreactionsfromitem.reactionon each itemloadMore(): appends next page; no-op ifloadingMoreor!hasMore; setsloadingMore = trueoptimistically; on error shows snackbar (error returned to caller, not thrown into AsyncError)setFeed(int? feedId): resetsitems,offset,hasMore,reactions; setsselectedFeedId; triggersbuild()-equivalent reload viastate = AsyncLoading()+ fetchtoggleReaction(int itemId, String reaction): optimistic toggle inreactionsmap; callsbriefingApi.postRssReactionordeleteRssReaction; reverts on error
feedsProvider extends AsyncNotifier<List<BriefingFeed>>
build(): fetches once; cached for the session (no invalidation needed — feeds rarely change)
Screen Behaviour
NewsScreen (ConsumerStatefulWidget)
AppBar: title "News", subtitle "Last 90 days"
Feed filter row (below app bar, above list): DropdownButton with "All feeds" option + one entry per feed. On change calls ref.read(newsProvider.notifier).setFeed(id).
List: ListView.builder of NewsCard widgets. Each NewsCard receives:
item:RssItemMeta.fromNewsItem(item)— a thin adapter sinceNewsCardalready usesRssItemMetareaction:newsState.reactions[item.id]onReaction: callsnotifier.toggleReactiononDiscuss: calls_handleDiscuss(item.id)
Load more: ListTile / FilledButton.tonal at the bottom — shows "Load more" when hasMore && !loadingMore, spinner when loadingMore, hidden when !hasMore.
Initial loading: CircularProgressIndicator centered. Error state shows message + "Retry" button that calls ref.invalidate(newsProvider).
Discuss flow (_handleDiscuss):
- Track
_openingChat = {itemId}in localsetState(disables that card's button while in flight) - Call
chatApi.openArticleInChat(itemId) - On success:
context.push(Routes.chat.replaceFirst(':id', '$conversationId')) - On error: show snackbar "Failed to open article in chat."
- Always: remove from
_openingChat
RssItemMeta Adapter
NewsCard currently consumes RssItemMeta (from news_card.dart). Add a factory on RssItemMeta:
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
id: item.id,
title: item.title,
url: item.url,
source: item.source,
snippet: item.snippet,
publishedAt: item.publishedAt,
);
This keeps NewsCard unchanged and avoids coupling the widget to a second model type.
What This Does NOT Include
- Feed management (add/remove/refresh feeds) — that is a Settings concern
- Offline caching
- Pull-to-refresh (load-more button is sufficient for the initial pass)