docs: add Android news screen implementation plan
This commit is contained in:
@@ -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<NewsState>`) 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<String> 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<String, dynamic> 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<dynamic>?)
|
||||
?.cast<String>()
|
||||
.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<String, dynamic> 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': <dynamic>[],
|
||||
'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<List<NewsItem>> getNewsItems({
|
||||
int days = 90,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
int? feedId,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'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<String, dynamic>;
|
||||
final list = data['items'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/feeds
|
||||
Future<List<BriefingFeed>> getFeeds() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/feeds');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
|
||||
.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<int> openArticleInChat(int itemId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
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<NewsApi>((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<NewsItem> items;
|
||||
final int offset;
|
||||
final bool hasMore;
|
||||
final bool loadingMore;
|
||||
final int? selectedFeedId;
|
||||
final Map<int, String?> reactions;
|
||||
|
||||
const NewsState({
|
||||
required this.items,
|
||||
required this.offset,
|
||||
required this.hasMore,
|
||||
required this.loadingMore,
|
||||
required this.selectedFeedId,
|
||||
required this.reactions,
|
||||
});
|
||||
|
||||
NewsState copyWith({
|
||||
List<NewsItem>? items,
|
||||
int? offset,
|
||||
bool? hasMore,
|
||||
bool? loadingMore,
|
||||
Object? selectedFeedId = _sentinel,
|
||||
Map<int, String?>? 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, NewsState>(NewsNotifier.new);
|
||||
|
||||
class NewsNotifier extends AsyncNotifier<NewsState> {
|
||||
static const _limit = 40;
|
||||
|
||||
@override
|
||||
Future<NewsState> 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<void> 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<int, String?>.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<void> 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, List<BriefingFeed>>(FeedsNotifier.new);
|
||||
|
||||
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
|
||||
@override
|
||||
Future<List<BriefingFeed>> 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<NewsScreen> createState() => _NewsScreenState();
|
||||
}
|
||||
|
||||
class _NewsScreenState extends ConsumerState<NewsScreen> {
|
||||
final Set<int> _openingChat = {};
|
||||
|
||||
Future<void> _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<void> _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<BriefingFeed> 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<int?>(
|
||||
value: selectedFeedId,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('All feeds'),
|
||||
),
|
||||
...feeds.map(
|
||||
(f) => DropdownMenuItem<int?>(
|
||||
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"
|
||||
```
|
||||
Reference in New Issue
Block a user