docs: add Android news screen design spec
This commit is contained in:
@@ -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<int>` |
|
||||||
|
| `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<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`
|
||||||
|
```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<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
|
||||||
|
```dart
|
||||||
|
// POST /api/chat/from-article/{itemId}
|
||||||
|
// Returns conversation_id
|
||||||
|
Future<int> openArticleInChat(int itemId) async { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `api_client_provider.dart` addition
|
||||||
|
```dart
|
||||||
|
final newsApiProvider = Provider<NewsApi>((ref) =>
|
||||||
|
NewsApi(ref.watch(dioProvider)));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### `NewsState`
|
||||||
|
```dart
|
||||||
|
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`:
|
||||||
|
```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)
|
||||||
Reference in New Issue
Block a user