This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/docs/superpowers/specs/2026-04-06-android-news-screen-design.md

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); initialises reactions from item.reaction on each item
  • loadMore(): appends next page; no-op if loadingMore or !hasMore; sets loadingMore = true optimistically; on error shows snackbar (error returned to caller, not thrown into AsyncError)
  • setFeed(int? feedId): resets items, offset, hasMore, reactions; sets selectedFeedId; triggers build()-equivalent reload via state = AsyncLoading() + fetch
  • toggleReaction(int itemId, String reaction): optimistic toggle in reactions map; calls briefingApi.postRssReaction or deleteRssReaction; 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 since NewsCard already uses RssItemMeta
  • reaction: newsState.reactions[item.id]
  • onReaction: calls notifier.toggleReaction
  • onDiscuss: 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):

  1. Track _openingChat = {itemId} in local setState (disables that card's button while in flight)
  2. Call chatApi.openArticleInChat(itemId)
  3. On success: context.push(Routes.chat.replaceFirst(':id', '$conversationId'))
  4. On error: show snackbar "Failed to open article in chat."
  5. 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)