diff --git a/docs/superpowers/specs/2026-04-06-android-news-screen-design.md b/docs/superpowers/specs/2026-04-06-android-news-screen-design.md new file mode 100644 index 0000000..133fe1c --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-android-news-screen-design.md @@ -0,0 +1,177 @@ +# 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` | +| `lib/providers/api_client_provider.dart` | Add `newsApiProvider` | + +--- + +## Data Models + +### `NewsItem` +```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; // 'up' | 'down' | null +} +``` +`fromJson` maps: `id`, `title`, `url`, `snippet`, `source`, `published_at` (nullable ISO string → `DateTime.tryParse`), `topics` (cast `List` → `List`), `reaction` (nullable string). + +### `BriefingFeed` +```dart +class BriefingFeed { + final int id; + final String title; + final String url; + final String? category; +} +``` + +--- + +## API Layer + +### `news_api.dart` + +```dart +class NewsApi { + final Dio _dio; + const NewsApi(this._dio); + + // GET /api/briefing/news + Future getNewsItems({ + int days = 90, + int limit = 40, + int offset = 0, + int? feedId, + }) async { ... } + + // GET /api/briefing/feeds + Future> getFeeds() async { ... } +} + +class NewsItemsResponse { + final List items; + final int offset; + final int limit; +} +``` + +### `chat_api.dart` addition +```dart +// POST /api/chat/from-article/{itemId} +// Returns conversation_id +Future openArticleInChat(int itemId) async { ... } +``` + +### `api_client_provider.dart` addition +```dart +final newsApiProvider = Provider((ref) => + NewsApi(ref.watch(dioProvider))); +``` + +--- + +## State Management + +### `NewsState` +```dart +class NewsState { + final List items; + final int offset; + final bool hasMore; + final bool loadingMore; + final int? selectedFeedId; + final Map reactions; // item id → 'up'|'down'|null +} +``` + +### `NewsNotifier extends AsyncNotifier` +- `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>` +- `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`: +```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, +); +``` +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)