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/plans/2026-04-06-android-news-screen.md

23 KiB

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:

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:

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 }):

  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:

import 'package:fabled_app/data/models/news_item.dart';
import 'package:fabled_app/data/models/briefing_feed.dart';
  • Step 4: Run tests
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
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:

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 }):

  /// 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):

import '../data/api/news_api.dart';

Add provider after voiceRepositoryProvider:

final newsApiProvider = Provider<NewsApi>((ref) {
  return NewsApi(ref.watch(dioProvider));
});
  • Step 4: Verify
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
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:

import '../data/models/news_item.dart';

Then add this factory inside the RssItemMeta class, after the existing fromJson factory:

  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
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/widgets/news_card.dart

Expected: No issues found!

  • Step 3: Commit
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:

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
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/providers/news_provider.dart

Expected: No issues found!

  • Step 3: Commit
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:

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
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/screens/news/news_screen.dart

Expected: No issues found!

  • Step 3: Commit
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:

import 'screens/news/news_screen.dart';

Find and replace the stub route:

      GoRoute(
        path: Routes.news,
        builder: (_, _) => Scaffold(
          appBar: AppBar(title: const Text('News')),
          body: const Center(child: Text('News — coming soon')),
        ),
      ),

Replace with:

      GoRoute(
        path: Routes.news,
        builder: (_, _) => const NewsScreen(),
      ),
  • Step 2: Full analyze and tests
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze
flutter test

Expected: No issues found! and all tests passed.

  • Step 3: Commit
git add lib/app.dart
git commit -m "feat: wire News route to NewsScreen"