From 39d9f7e0530e0e92a0581eb11d1790e9e64fe981 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 06:35:31 -0400 Subject: [PATCH 01/28] docs: add Android nav restructure + projects staleness fix spec --- ...26-04-06-android-nav-restructure-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-android-nav-restructure-design.md diff --git a/docs/superpowers/specs/2026-04-06-android-nav-restructure-design.md b/docs/superpowers/specs/2026-04-06-android-nav-restructure-design.md new file mode 100644 index 0000000..d412df7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-android-nav-restructure-design.md @@ -0,0 +1,136 @@ +# Android Nav Restructure & Projects Staleness Fix + +> **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:** Replace the Projects bottom-nav tab with a "More" bottom sheet that houses Projects, News, and Calendar; fix stale task data on the project tasks screen. + +**Architecture:** The shell's 4th nav item becomes a non-routing action — tapping it shows a `showModalBottomSheet` with the three overflow destinations. `_tabIndex()` maps `/projects`, `/news`, `/calendar` prefixes to index 3 so the "More" tab highlights correctly. News and Calendar are added as stub push-routes now; their real screens are built in subsequent passes. The staleness fix is a single `await` + `ref.invalidate` after returning from task edit. + +**Tech Stack:** Flutter, GoRouter, Riverpod, Material 3 `NavigationBar` / `NavigationRail` + +--- + +## Scope + +Two independent changes in one pass: + +1. **Nav restructure** — `lib/app.dart` +2. **Staleness fix** — `lib/screens/library/project_tasks_screen.dart` + +--- + +## Design Details + +### 1. Nav Restructure (`lib/app.dart`) + +**Route changes:** +- Remove `/projects` from the `ShellRoute` routes list. +- Add three new top-level `GoRoute` entries (alongside the existing non-shell routes): + - `/projects` → `ProjectsScreen()` (moved from shell) + - `/news` → stub `Scaffold(body: Center(child: Text('News — coming soon')))` + - `/calendar` → stub `Scaffold(body: Center(child: Text('Calendar — coming soon')))` +- Add route constants to `lib/core/constants.dart`: `news = '/news'`, `calendar = '/calendar'` + +**Shell tab list:** +```dart +static const _tabs = [ + Routes.briefing, + Routes.knowledge, + Routes.conversations, +]; +``` +(3 entries — "More" is index 3 but handled specially, not a route) + +**`_tabIndex()` update:** +```dart +int _tabIndex(String location) { + for (var i = 0; i < _tabs.length; i++) { + if (location.startsWith(_tabs[i])) return i; + } + // Projects, News, Calendar all highlight "More" + if (location.startsWith(Routes.projects) || + location.startsWith(Routes.news) || + location.startsWith(Routes.calendar)) return 3; + return 0; +} +``` + +**`onDestinationSelected` update (both `NavigationBar` and `NavigationRail`):** +```dart +onDestinationSelected: (i) { + if (i == 3) { + _showMoreSheet(context); + } else { + context.go(_tabs[i]); + } +}, +``` + +**`_showMoreSheet` method on `_ShellState`:** +```dart +void _showMoreSheet(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.folder_outlined), + title: const Text('Projects'), + onTap: () { + Navigator.pop(context); + context.push(Routes.projects); + }, + ), + ListTile( + leading: const Icon(Icons.newspaper_outlined), + title: const Text('News'), + onTap: () { + Navigator.pop(context); + context.push(Routes.news); + }, + ), + ListTile( + leading: const Icon(Icons.calendar_month_outlined), + title: const Text('Calendar'), + onTap: () { + Navigator.pop(context); + context.push(Routes.calendar); + }, + ), + ], + ), + ), + ); +} +``` + +**4th nav destination label:** "More" with `Icons.more_horiz_outlined` / `Icons.more_horiz`. + +**NavigationRail note:** The wide-layout rail also gets the same 4 destinations and the same intercept on `onDestinationSelected`. + +--- + +### 2. Projects Staleness Fix (`lib/screens/library/project_tasks_screen.dart`) + +**Current (line ~321):** +```dart +context.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')); +``` + +**Fixed:** +```dart +await context.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')); +ref.invalidate(projectTasksProvider(widget.projectId)); +``` + +This re-fetches tasks as soon as the user pops back from the task edit screen, eliminating stale data. + +--- + +## What This Does NOT Include + +- Real News screen implementation (separate spec/pass) +- Real Calendar screen implementation (separate spec/pass) +- Any changes to the web frontend From a23af0658ab322413a98c5e6327eb57eaea2f2c4 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 06:40:06 -0400 Subject: [PATCH 02/28] docs: add Android nav restructure implementation plan --- .../2026-04-06-android-nav-restructure.md | 521 ++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-android-nav-restructure.md diff --git a/docs/superpowers/plans/2026-04-06-android-nav-restructure.md b/docs/superpowers/plans/2026-04-06-android-nav-restructure.md new file mode 100644 index 0000000..4b207e9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-android-nav-restructure.md @@ -0,0 +1,521 @@ +# Android Nav Restructure & Projects Staleness Fix 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:** Replace the Projects bottom-nav tab with a "More" bottom sheet giving access to Projects, News, and Calendar; fix stale task data when returning from task edit. + +**Architecture:** The shell's `_tabs` list shrinks from 4 to 3 entries; the 4th nav destination ("More") is intercepted in `onDestinationSelected` to show a `showModalBottomSheet` instead of navigating. Projects moves from a ShellRoute to a top-level push route. News and Calendar are added as stub push-routes. `_tabIndex` maps the three overflow paths to index 3 so "More" highlights correctly. The staleness fix moves the task-tap callback out of the stateless `_TaskRow` widget into the parent `ConsumerState` where `ref` is available, using `.then()` to invalidate after pop. + +**Tech Stack:** Flutter, GoRouter, Riverpod, Material 3 + +--- + +## Files + +| File | Action | What changes | +|------|--------|-------------| +| `lib/core/constants.dart` | Modify | Add `news` and `calendar` route constants | +| `lib/app.dart` | Modify | Remove Projects from ShellRoute, add 3 push routes, shrink `_tabs`, add `_showMoreSheet`, update `_tabIndex`, update both nav widgets | +| `lib/screens/library/project_tasks_screen.dart` | Modify | Add `onTap: VoidCallback` to `_TaskRow`; add `_openTask` method to state; pass callback at both call sites | + +--- + +### Task 1: Add route constants + +**Files:** +- Modify: `lib/core/constants.dart` + +- [ ] **Step 1: Add `news` and `calendar` to `Routes`** + +Open `lib/core/constants.dart`. The file currently ends with: +```dart +abstract class Routes { + static const splash = '/'; + static const setup = '/setup'; + static const login = '/login'; + static const notes = '/notes'; + static const noteDetail = '/notes/:id'; + static const noteEdit = '/notes/:id/edit'; + static const noteNew = '/notes/new'; + static const tasks = '/tasks'; + static const taskNew = '/tasks/new'; + static const taskEdit = '/tasks/:id/edit'; + static const knowledge = '/knowledge'; + static const projects = '/projects'; + static const projectEdit = '/projects/:id/edit'; + static const conversations = '/chat'; + static const chat = '/chat/:id'; + static const quickCapture = '/quick-capture'; + static const settings = '/settings'; + static const briefing = '/briefing'; + static const projectTasks = '/projects/:id/tasks'; +} +``` + +Add two constants after `briefing`: +```dart +abstract class Routes { + static const splash = '/'; + static const setup = '/setup'; + static const login = '/login'; + static const notes = '/notes'; + static const noteDetail = '/notes/:id'; + static const noteEdit = '/notes/:id/edit'; + static const noteNew = '/notes/new'; + static const tasks = '/tasks'; + static const taskNew = '/tasks/new'; + static const taskEdit = '/tasks/:id/edit'; + static const knowledge = '/knowledge'; + static const projects = '/projects'; + static const projectEdit = '/projects/:id/edit'; + static const conversations = '/chat'; + static const chat = '/chat/:id'; + static const quickCapture = '/quick-capture'; + static const settings = '/settings'; + static const briefing = '/briefing'; + static const news = '/news'; + static const calendar = '/calendar'; + static const projectTasks = '/projects/:id/tasks'; +} +``` + +- [ ] **Step 2: Verify no analysis errors** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/core/constants.dart +``` +Expected: `No issues found!` + +--- + +### Task 2: Restructure shell and add routes in `app.dart` + +**Files:** +- Modify: `lib/app.dart` + +This task has several sub-steps. Make them all before running analyze. + +- [ ] **Step 1: Move Projects out of ShellRoute, add three push routes** + +Find the `ShellRoute` block (currently lines ~142–162): +```dart + ShellRoute( + builder: (context, state, child) => _Shell(child: child), + routes: [ + GoRoute( + path: Routes.briefing, + builder: (_, _) => const BriefingScreen(), + ), + GoRoute( + path: Routes.knowledge, + builder: (_, _) => const KnowledgeScreen(), + ), + GoRoute( + path: Routes.conversations, + builder: (_, _) => const ConversationsTabScreen(), + ), + GoRoute( + path: Routes.projects, + builder: (_, _) => const ProjectsScreen(), + ), + ], + ), +``` + +Replace with (Projects removed from shell; three new top-level routes added after the closing `],` of the ShellRoute): +```dart + ShellRoute( + builder: (context, state, child) => _Shell(child: child), + routes: [ + GoRoute( + path: Routes.briefing, + builder: (_, _) => const BriefingScreen(), + ), + GoRoute( + path: Routes.knowledge, + builder: (_, _) => const KnowledgeScreen(), + ), + GoRoute( + path: Routes.conversations, + builder: (_, _) => const ConversationsTabScreen(), + ), + ], + ), + GoRoute( + path: Routes.projects, + builder: (_, _) => const ProjectsScreen(), + ), + GoRoute( + path: Routes.news, + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('News')), + body: const Center(child: Text('News — coming soon')), + ), + ), + GoRoute( + path: Routes.calendar, + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('Calendar')), + body: const Center(child: Text('Calendar — coming soon')), + ), + ), +``` + +- [ ] **Step 2: Shrink `_tabs` from 4 to 3 entries** + +Find in `_ShellState`: +```dart + static const _tabs = [ + Routes.briefing, + Routes.knowledge, + Routes.conversations, + Routes.projects, + ]; +``` + +Replace with: +```dart + static const _tabs = [ + Routes.briefing, + Routes.knowledge, + Routes.conversations, + ]; +``` + +- [ ] **Step 3: Update `_tabIndex` to map overflow routes to index 3** + +Find: +```dart + int _tabIndex(String location) { + for (var i = 0; i < _tabs.length; i++) { + if (location.startsWith(_tabs[i])) return i; + } + return 0; + } +``` + +Replace with: +```dart + int _tabIndex(String location) { + for (var i = 0; i < _tabs.length; i++) { + if (location.startsWith(_tabs[i])) return i; + } + if (location.startsWith(Routes.projects) || + location.startsWith(Routes.news) || + location.startsWith(Routes.calendar)) return 3; + return 0; + } +``` + +- [ ] **Step 4: Add `_showMoreSheet` method to `_ShellState`** + +Add this method anywhere in `_ShellState`, e.g. just before `build`: +```dart + void _showMoreSheet(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.folder_outlined), + title: const Text('Projects'), + onTap: () { + Navigator.pop(context); + context.push(Routes.projects); + }, + ), + ListTile( + leading: const Icon(Icons.newspaper_outlined), + title: const Text('News'), + onTap: () { + Navigator.pop(context); + context.push(Routes.news); + }, + ), + ListTile( + leading: const Icon(Icons.calendar_month_outlined), + title: const Text('Calendar'), + onTap: () { + Navigator.pop(context); + context.push(Routes.calendar); + }, + ), + ], + ), + ), + ); + } +``` + +- [ ] **Step 5: Update `NavigationRail` — intercept index 3 and swap destination** + +Find in the wide-layout branch: +```dart + NavigationRail( + selectedIndex: index, + onDestinationSelected: (i) => context.go(_tabs[i]), + labelType: NavigationRailLabelType.all, + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: Text('Briefing'), + ), + NavigationRailDestination( + icon: Icon(Icons.menu_book_outlined), + selectedIcon: Icon(Icons.menu_book), + label: Text('Knowledge'), + ), + NavigationRailDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: Text('Chat'), + ), + NavigationRailDestination( + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), + label: Text('Projects'), + ), + ], + ), +``` + +Replace with: +```dart + NavigationRail( + selectedIndex: index, + onDestinationSelected: (i) { + if (i == 3) { + _showMoreSheet(context); + } else { + context.go(_tabs[i]); + } + }, + labelType: NavigationRailLabelType.all, + destinations: const [ + NavigationRailDestination( + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: Text('Briefing'), + ), + NavigationRailDestination( + icon: Icon(Icons.menu_book_outlined), + selectedIcon: Icon(Icons.menu_book), + label: Text('Knowledge'), + ), + NavigationRailDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: Text('Chat'), + ), + NavigationRailDestination( + icon: Icon(Icons.more_horiz_outlined), + selectedIcon: Icon(Icons.more_horiz), + label: Text('More'), + ), + ], + ), +``` + +- [ ] **Step 6: Update `NavigationBar` — intercept index 3 and swap destination** + +Find in the narrow-layout branch: +```dart + bottomNavigationBar: NavigationBar( + selectedIndex: index, + onDestinationSelected: (i) => context.go(_tabs[i]), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: 'Briefing', + ), + NavigationDestination( + icon: Icon(Icons.menu_book_outlined), + selectedIcon: Icon(Icons.menu_book), + label: 'Knowledge', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chat', + ), + NavigationDestination( + icon: Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder), + label: 'Projects', + ), + ], + ), +``` + +Replace with: +```dart + bottomNavigationBar: NavigationBar( + selectedIndex: index, + onDestinationSelected: (i) { + if (i == 3) { + _showMoreSheet(context); + } else { + context.go(_tabs[i]); + } + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.wb_sunny_outlined), + selectedIcon: Icon(Icons.wb_sunny), + label: 'Briefing', + ), + NavigationDestination( + icon: Icon(Icons.menu_book_outlined), + selectedIcon: Icon(Icons.menu_book), + label: 'Knowledge', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chat', + ), + NavigationDestination( + icon: Icon(Icons.more_horiz_outlined), + selectedIcon: Icon(Icons.more_horiz), + label: 'More', + ), + ], + ), +``` + +- [ ] **Step 7: Verify no analysis errors** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/app.dart lib/core/constants.dart +``` +Expected: `No issues found!` + +- [ ] **Step 8: Commit** + +```bash +git add lib/core/constants.dart lib/app.dart +git commit -m "feat: replace Projects tab with More bottom sheet (news/calendar stubs)" +``` + +--- + +### Task 3: Fix stale tasks after returning from task edit + +**Files:** +- Modify: `lib/screens/library/project_tasks_screen.dart` + +- [ ] **Step 1: Add `_openTask` method to `_ProjectTasksScreenState`** + +`_ProjectTasksScreenState` is a `ConsumerState` — it has `ref` and `context`. Find the class body (look for `_cycleStatus` method as a landmark) and add `_openTask` as a sibling method: + +```dart + void _openTask(int taskId) { + context + .push(Routes.taskEdit.replaceFirst(':id', '$taskId')) + .then((_) => ref.invalidate(projectTasksProvider(widget.projectId))); + } +``` + +- [ ] **Step 2: Add `onTap` parameter to `_TaskRow`** + +Find the `_TaskRow` class definition: +```dart +class _TaskRow extends StatelessWidget { + final Task task; + final TaskStatus effectiveStatus; + final VoidCallback onStatusTap; + + const _TaskRow({ + required this.task, + required this.effectiveStatus, + required this.onStatusTap, + }); +``` + +Replace with: +```dart +class _TaskRow extends StatelessWidget { + final Task task; + final TaskStatus effectiveStatus; + final VoidCallback onStatusTap; + final VoidCallback onTap; + + const _TaskRow({ + required this.task, + required this.effectiveStatus, + required this.onStatusTap, + required this.onTap, + }); +``` + +- [ ] **Step 3: Use the `onTap` callback in `InkWell`** + +Find inside `_TaskRow.build`: +```dart + child: InkWell( + onTap: () => context + .push(Routes.taskEdit.replaceFirst(':id', '${task.id}')), +``` + +Replace with: +```dart + child: InkWell( + onTap: onTap, +``` + +- [ ] **Step 4: Pass `onTap` at both `_TaskRow` call sites** + +There are two places in `_buildBody` where `_TaskRow` is instantiated. Update both: + +**First call site (milestone tasks, around line 170):** +```dart + return _TaskRow( + task: task, + effectiveStatus: _effectiveStatus(task), + onStatusTap: () => _cycleStatus(task), + onTap: () => _openTask(task.id), + ); +``` + +**Second call site (unassigned tasks, around line 197):** +```dart + return _TaskRow( + task: task, + effectiveStatus: _effectiveStatus(task), + onStatusTap: () => _cycleStatus(task), + onTap: () => _openTask(task.id), + ); +``` + +- [ ] **Step 5: Verify no analysis errors** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/screens/library/project_tasks_screen.dart +``` +Expected: `No issues found!` + +- [ ] **Step 6: Commit** + +```bash +git add lib/screens/library/project_tasks_screen.dart +git commit -m "fix: invalidate project tasks on return from task edit screen" +``` + +--- + +### Task 4: Final check + +- [ ] **Step 1: Full analyze** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze +``` +Expected: `No issues found!` From 36bc36cd9daef829acc0193d46e50d1559eff2b2 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 07:14:38 -0400 Subject: [PATCH 03/28] feat: replace Projects tab with More bottom sheet (news/calendar stubs) --- lib/app.dart | 93 +++++++++++++++++++++++++++++++++++------ lib/core/constants.dart | 2 + 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 8c7f297..0e69b17 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -154,12 +154,26 @@ final routerProvider = Provider((ref) { path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen(), ), - GoRoute( - path: Routes.projects, - builder: (_, _) => const ProjectsScreen(), - ), ], ), + GoRoute( + path: Routes.projects, + builder: (_, _) => const ProjectsScreen(), + ), + GoRoute( + path: Routes.news, + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('News')), + body: const Center(child: Text('News — coming soon')), + ), + ), + GoRoute( + path: Routes.calendar, + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('Calendar')), + body: const Center(child: Text('Calendar — coming soon')), + ), + ), ], ); }); @@ -177,7 +191,6 @@ class _ShellState extends ConsumerState<_Shell> { Routes.briefing, Routes.knowledge, Routes.conversations, - Routes.projects, ]; @override @@ -210,9 +223,51 @@ class _ShellState extends ConsumerState<_Shell> { for (var i = 0; i < _tabs.length; i++) { if (location.startsWith(_tabs[i])) return i; } + if (location.startsWith(Routes.projects) || + location.startsWith(Routes.news) || + location.startsWith(Routes.calendar)) { + return 3; + } return 0; } + void _showMoreSheet(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.folder_outlined), + title: const Text('Projects'), + onTap: () { + Navigator.pop(context); + context.push(Routes.projects); + }, + ), + ListTile( + leading: const Icon(Icons.newspaper_outlined), + title: const Text('News'), + onTap: () { + Navigator.pop(context); + context.push(Routes.news); + }, + ), + ListTile( + leading: const Icon(Icons.calendar_month_outlined), + title: const Text('Calendar'), + onTap: () { + Navigator.pop(context); + context.push(Routes.calendar); + }, + ), + ], + ), + ), + ); + } + void _showUpdateDialog(UpdateState update) { showDialog( context: context, @@ -303,7 +358,13 @@ class _ShellState extends ConsumerState<_Shell> { children: [ NavigationRail( selectedIndex: index, - onDestinationSelected: (i) => context.go(_tabs[i]), + onDestinationSelected: (i) { + if (i == 3) { + _showMoreSheet(context); + } else { + context.go(_tabs[i]); + } + }, labelType: NavigationRailLabelType.all, destinations: const [ NavigationRailDestination( @@ -322,9 +383,9 @@ class _ShellState extends ConsumerState<_Shell> { label: Text('Chat'), ), NavigationRailDestination( - icon: Icon(Icons.folder_outlined), - selectedIcon: Icon(Icons.folder), - label: Text('Projects'), + icon: Icon(Icons.more_horiz_outlined), + selectedIcon: Icon(Icons.more_horiz), + label: Text('More'), ), ], ), @@ -354,7 +415,13 @@ class _ShellState extends ConsumerState<_Shell> { ), bottomNavigationBar: NavigationBar( selectedIndex: index, - onDestinationSelected: (i) => context.go(_tabs[i]), + onDestinationSelected: (i) { + if (i == 3) { + _showMoreSheet(context); + } else { + context.go(_tabs[i]); + } + }, destinations: const [ NavigationDestination( icon: Icon(Icons.wb_sunny_outlined), @@ -372,9 +439,9 @@ class _ShellState extends ConsumerState<_Shell> { label: 'Chat', ), NavigationDestination( - icon: Icon(Icons.folder_outlined), - selectedIcon: Icon(Icons.folder), - label: 'Projects', + icon: Icon(Icons.more_horiz_outlined), + selectedIcon: Icon(Icons.more_horiz), + label: 'More', ), ], ), diff --git a/lib/core/constants.dart b/lib/core/constants.dart index bf4b3d1..2de5da1 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -17,5 +17,7 @@ abstract class Routes { static const quickCapture = '/quick-capture'; static const settings = '/settings'; static const briefing = '/briefing'; + static const news = '/news'; + static const calendar = '/calendar'; static const projectTasks = '/projects/:id/tasks'; } From 95d0f529ead5715feb05251faa0b4b98fbba5fd6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 07:15:35 -0400 Subject: [PATCH 04/28] fix: invalidate project tasks on return from task edit screen --- lib/screens/library/project_tasks_screen.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/screens/library/project_tasks_screen.dart b/lib/screens/library/project_tasks_screen.dart index 9724df3..fb41c15 100644 --- a/lib/screens/library/project_tasks_screen.dart +++ b/lib/screens/library/project_tasks_screen.dart @@ -48,6 +48,12 @@ class _ProjectTasksScreenState extends ConsumerState { } } + void _openTask(int taskId) { + context + .push(Routes.taskEdit.replaceFirst(':id', '$taskId')) + .then((_) => ref.invalidate(projectTasksProvider(widget.projectId))); + } + Color _parseColor(String? hex) { if (hex == null || hex.isEmpty) return const Color(0xFF6366F1); try { @@ -171,6 +177,7 @@ class _ProjectTasksScreenState extends ConsumerState { task: task, effectiveStatus: _effectiveStatus(task), onStatusTap: () => _cycleStatus(task), + onTap: () => _openTask(task.id), ); }, ), @@ -198,6 +205,7 @@ class _ProjectTasksScreenState extends ConsumerState { task: task, effectiveStatus: _effectiveStatus(task), onStatusTap: () => _cycleStatus(task), + onTap: () => _openTask(task.id), ); }, ), @@ -282,11 +290,13 @@ class _TaskRow extends StatelessWidget { final Task task; final TaskStatus effectiveStatus; final VoidCallback onStatusTap; + final VoidCallback onTap; const _TaskRow({ required this.task, required this.effectiveStatus, required this.onStatusTap, + required this.onTap, }); IconData get _statusIcon => switch (effectiveStatus) { @@ -317,8 +327,7 @@ class _TaskRow extends StatelessWidget { return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), child: InkWell( - onTap: () => context - .push(Routes.taskEdit.replaceFirst(':id', '${task.id}')), + onTap: onTap, borderRadius: BorderRadius.circular(14), child: Padding( padding: const EdgeInsets.fromLTRB(4, 6, 14, 6), From 2a2f9e6e85a0b5edeee059fd08dc7686843625c4 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:04:18 -0400 Subject: [PATCH 05/28] docs: add Android news screen design spec --- .../2026-04-06-android-news-screen-design.md | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-android-news-screen-design.md 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) From a2fc0d6c7df8ccb184372629480149e301b046b6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:11:12 -0400 Subject: [PATCH 06/28] docs: add Android news screen implementation plan --- .../plans/2026-04-06-android-news-screen.md | 823 ++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-android-news-screen.md diff --git a/docs/superpowers/plans/2026-04-06-android-news-screen.md b/docs/superpowers/plans/2026-04-06-android-news-screen.md new file mode 100644 index 0000000..b5e43c1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-android-news-screen.md @@ -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`) 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 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 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?) + ?.cast() + .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 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': [], + '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> getNewsItems({ + int days = 90, + int limit = 40, + int offset = 0, + int? feedId, + }) async { + try { + final params = { + '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; + final list = data['items'] as List; + return list + .map((e) => NewsItem.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// GET /api/briefing/feeds + Future> getFeeds() async { + try { + final response = await _dio.get('/api/briefing/feeds'); + final list = response.data as List; + return list + .map((e) => BriefingFeed.fromJson(e as Map)) + .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 openArticleInChat(int itemId) async { + try { + final response = + await _dio.post('/api/chat/from-article/$itemId', data: {}); + final data = response.data as Map; + 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((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 items; + final int offset; + final bool hasMore; + final bool loadingMore; + final int? selectedFeedId; + final Map reactions; + + const NewsState({ + required this.items, + required this.offset, + required this.hasMore, + required this.loadingMore, + required this.selectedFeedId, + required this.reactions, + }); + + NewsState copyWith({ + List? items, + int? offset, + bool? hasMore, + bool? loadingMore, + Object? selectedFeedId = _sentinel, + Map? 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.new); + +class NewsNotifier extends AsyncNotifier { + static const _limit = 40; + + @override + Future 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 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.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 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.new); + +class FeedsNotifier extends AsyncNotifier> { + @override + Future> 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 createState() => _NewsScreenState(); +} + +class _NewsScreenState extends ConsumerState { + final Set _openingChat = {}; + + Future _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 _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 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( + value: selectedFeedId, + underline: const SizedBox.shrink(), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All feeds'), + ), + ...feeds.map( + (f) => DropdownMenuItem( + 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" +``` From 77fc82af45481db68c1ac47c24a25409def1de67 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:13:06 -0400 Subject: [PATCH 07/28] feat: add NewsItem and BriefingFeed models with tests --- lib/data/models/briefing_feed.dart | 20 +++++++++ lib/data/models/news_item.dart | 37 ++++++++++++++++ test/widget_test.dart | 70 ++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 lib/data/models/briefing_feed.dart create mode 100644 lib/data/models/news_item.dart diff --git a/lib/data/models/briefing_feed.dart b/lib/data/models/briefing_feed.dart new file mode 100644 index 0000000..6bfc71e --- /dev/null +++ b/lib/data/models/briefing_feed.dart @@ -0,0 +1,20 @@ +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 json) => BriefingFeed( + id: json['id'] as int, + title: json['title'] as String? ?? '', + url: json['url'] as String? ?? '', + category: json['category'] as String?, + ); +} diff --git a/lib/data/models/news_item.dart b/lib/data/models/news_item.dart new file mode 100644 index 0000000..23ca547 --- /dev/null +++ b/lib/data/models/news_item.dart @@ -0,0 +1,37 @@ +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; + + 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 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?) + ?.cast() + .toList() ?? + [], + reaction: json['reaction'] as String?, + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 667fdd2..3820264 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -2,6 +2,8 @@ import 'package:fabled_app/data/api/voice_api.dart'; import 'package:fabled_app/providers/voice_provider.dart'; import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/note.dart'; +import 'package:fabled_app/data/models/news_item.dart'; +import 'package:fabled_app/data/models/briefing_feed.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -146,4 +148,72 @@ void main() { equals('item one item two')); }); }); + + 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': [], + '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); + }); + }); } From b56f3d3a0f83eff39e4757989c1b64824d57b920 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:16:00 -0400 Subject: [PATCH 08/28] feat: add NewsApi, openArticleInChat, and newsApiProvider --- lib/data/api/chat_api.dart | 14 +++++++ lib/data/api/news_api.dart | 52 ++++++++++++++++++++++++++ lib/providers/api_client_provider.dart | 5 +++ 3 files changed, 71 insertions(+) create mode 100644 lib/data/api/news_api.dart diff --git a/lib/data/api/chat_api.dart b/lib/data/api/chat_api.dart index c3dfef0..4692812 100644 --- a/lib/data/api/chat_api.dart +++ b/lib/data/api/chat_api.dart @@ -127,4 +127,18 @@ class ChatApi { throw dioToApp(e); } } + + /// POST /api/chat/from-article/{itemId} + /// Creates or retrieves a chat conversation seeded with the article. + /// Returns the conversation_id. + Future openArticleInChat(int itemId) async { + try { + final response = + await _dio.post('/api/chat/from-article/$itemId', data: {}); + final data = response.data as Map; + return data['conversation_id'] as int; + } on DioException catch (e) { + throw dioToApp(e); + } + } } diff --git a/lib/data/api/news_api.dart b/lib/data/api/news_api.dart new file mode 100644 index 0000000..3dc94d9 --- /dev/null +++ b/lib/data/api/news_api.dart @@ -0,0 +1,52 @@ +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> getNewsItems({ + int days = 90, + int limit = 40, + int offset = 0, + int? feedId, + }) async { + try { + final params = { + '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; + final list = data['items'] as List; + return list + .map((e) => NewsItem.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// GET /api/briefing/feeds + Future> getFeeds() async { + try { + final response = await _dio.get('/api/briefing/feeds'); + final list = response.data as List; + return list + .map((e) => BriefingFeed.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index e9b2348..be162ad 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -9,6 +9,7 @@ import '../data/api/chat_api.dart'; import '../data/api/knowledge_api.dart'; import '../data/api/voice_api.dart'; import '../data/api/milestones_api.dart'; +import '../data/api/news_api.dart'; import '../data/api/notes_api.dart'; import '../data/api/projects_api.dart'; import '../data/api/quick_capture_api.dart'; @@ -110,3 +111,7 @@ final voiceApiProvider = Provider((ref) { final voiceRepositoryProvider = Provider((ref) { return VoiceRepository(ref.watch(voiceApiProvider)); }); + +final newsApiProvider = Provider((ref) { + return NewsApi(ref.watch(dioProvider)); +}); From 01ea6b48db48aa079f51aa5cd47fbc40e47f8323 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:18:34 -0400 Subject: [PATCH 09/28] feat: add RssItemMeta.fromNewsItem adapter factory --- lib/widgets/news_card.dart | 48 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/widgets/news_card.dart b/lib/widgets/news_card.dart index c048f76..43a58bf 100644 --- a/lib/widgets/news_card.dart +++ b/lib/widgets/news_card.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../data/models/news_item.dart'; + class RssItemMeta { final int id; final String title; @@ -29,6 +31,15 @@ class RssItemMeta { : null, ); + factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta( + id: item.id, + title: item.title, + url: item.url, + source: item.source, + snippet: item.snippet, + publishedAt: item.publishedAt, + ); + String get relativeDate { if (publishedAt == null) return ''; final diff = DateTime.now().difference(publishedAt!); @@ -42,12 +53,14 @@ class NewsCard extends StatelessWidget { final RssItemMeta item; final String? reaction; // 'up' | 'down' | null final void Function(int itemId, String reaction) onReaction; + final VoidCallback? onDiscuss; const NewsCard({ super.key, required this.item, required this.reaction, required this.onReaction, + this.onDiscuss, }); Future _openUrl() async { @@ -127,9 +140,8 @@ class NewsCard extends StatelessWidget { ), ], const SizedBox(height: 8), - // Reaction buttons + // Actions row: reactions + discuss Row( - mainAxisSize: MainAxisSize.min, children: [ _ReactionButton( emoji: '👍', @@ -142,6 +154,10 @@ class NewsCard extends StatelessWidget { active: reaction == 'down', onTap: () => onReaction(item.id, 'down'), ), + if (onDiscuss != null) ...[ + const Spacer(), + _DiscussButton(onTap: onDiscuss!), + ], ], ), ], @@ -151,6 +167,34 @@ class NewsCard extends StatelessWidget { } } +class _DiscussButton extends StatelessWidget { + final VoidCallback onTap; + const _DiscussButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + border: Border.all(color: scheme.primary.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Discuss', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: scheme.primary, + ), + ), + ), + ); + } +} + class _ReactionButton extends StatelessWidget { final String emoji; final bool active; From e08a8906e372eb974e3c7339d3cbc5dacf24deb2 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:22:24 -0400 Subject: [PATCH 10/28] feat: add NewsNotifier and feedsProvider Co-Authored-By: Claude Sonnet 4.6 --- lib/providers/news_provider.dart | 157 +++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 lib/providers/news_provider.dart diff --git a/lib/providers/news_provider.dart b/lib/providers/news_provider.dart new file mode 100644 index 0000000..18b3a66 --- /dev/null +++ b/lib/providers/news_provider.dart @@ -0,0 +1,157 @@ +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 items; + final int offset; + final bool hasMore; + final bool loadingMore; + final int? selectedFeedId; + final Map reactions; + + const NewsState({ + required this.items, + required this.offset, + required this.hasMore, + required this.loadingMore, + required this.selectedFeedId, + required this.reactions, + }); + + NewsState copyWith({ + List? items, + int? offset, + bool? hasMore, + bool? loadingMore, + Object? selectedFeedId = _sentinel, + Map? 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.new); + +class NewsNotifier extends AsyncNotifier { + static const _limit = 40; + + @override + Future 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 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.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 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.new); + +class FeedsNotifier extends AsyncNotifier> { + @override + Future> build() async { + return ref.read(newsApiProvider).getFeeds(); + } +} From f4e39c00eb07376d05b4dae3f78a68413f72afd6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:26:59 -0400 Subject: [PATCH 11/28] fix: use ref.watch in build() and avoid stale snapshot in loadMore catch --- lib/providers/news_provider.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/providers/news_provider.dart b/lib/providers/news_provider.dart index 18b3a66..2077a0c 100644 --- a/lib/providers/news_provider.dart +++ b/lib/providers/news_provider.dart @@ -56,7 +56,7 @@ class NewsNotifier extends AsyncNotifier { @override Future build() async { - final items = await ref.read(newsApiProvider).getNewsItems( + final items = await ref.watch(newsApiProvider).getNewsItems( days: 90, limit: _limit, offset: 0, @@ -94,7 +94,8 @@ class NewsNotifier extends AsyncNotifier { reactions: updatedReactions, )); } catch (e) { - state = AsyncData(current.copyWith(loadingMore: false)); + final recovered = state.value ?? current; + state = AsyncData(recovered.copyWith(loadingMore: false)); rethrow; } } @@ -152,6 +153,6 @@ final feedsProvider = class FeedsNotifier extends AsyncNotifier> { @override Future> build() async { - return ref.read(newsApiProvider).getFeeds(); + return ref.watch(newsApiProvider).getFeeds(); } } From e7d174cef79adafed6e6c8923515d7c9075215d1 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:29:30 -0400 Subject: [PATCH 12/28] feat: add NewsScreen with feed filter, reactions, and discuss --- lib/screens/news/news_screen.dart | 184 ++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 lib/screens/news/news_screen.dart diff --git a/lib/screens/news/news_screen.dart b/lib/screens/news/news_screen.dart new file mode 100644 index 0000000..3094581 --- /dev/null +++ b/lib/screens/news/news_screen.dart @@ -0,0 +1,184 @@ +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 createState() => _NewsScreenState(); +} + +class _NewsScreenState extends ConsumerState { + final Set _openingChat = {}; + + Future _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 _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: (e, _) => 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 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( + value: selectedFeedId, + underline: const SizedBox.shrink(), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All feeds'), + ), + ...feeds.map( + (f) => DropdownMenuItem( + value: f.id, + child: Text(f.title), + ), + ), + ], + onChanged: (v) => onChanged(v), + ), + ], + ), + ); + } +} From 3a0722196815ba3c3ea6b4e37b35024bfba212c5 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 08:30:08 -0400 Subject: [PATCH 13/28] feat: wire News route to NewsScreen --- lib/app.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 0e69b17..c1d6b0d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -26,6 +26,7 @@ import 'screens/projects/project_edit_screen.dart'; import 'screens/projects/projects_screen.dart'; import 'screens/notes/note_edit_screen.dart'; import 'screens/settings/settings_screen.dart'; +import 'screens/news/news_screen.dart'; import 'screens/setup/setup_screen.dart'; import 'screens/splash/splash_screen.dart'; import 'screens/tasks/task_edit_screen.dart'; @@ -162,10 +163,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: Routes.news, - builder: (_, _) => Scaffold( - appBar: AppBar(title: const Text('News')), - body: const Center(child: Text('News — coming soon')), - ), + builder: (_, _) => const NewsScreen(), ), GoRoute( path: Routes.calendar, From 8e5a95b0f2badc83e09646c361de0fd6c1386747 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:01:37 -0400 Subject: [PATCH 14/28] docs: add Android Calendar screen design spec --- ...26-04-06-android-calendar-screen-design.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-android-calendar-screen-design.md diff --git a/docs/superpowers/specs/2026-04-06-android-calendar-screen-design.md b/docs/superpowers/specs/2026-04-06-android-calendar-screen-design.md new file mode 100644 index 0000000..2aaaf3d --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-android-calendar-screen-design.md @@ -0,0 +1,199 @@ +# Android Calendar Screen Design + +**Goal:** Build the Android Calendar screen — a month-strip + daily agenda view with full event CRUD (create, edit, delete) including a simple recurrence picker. + +**Architecture:** `CalendarNotifier` (`AsyncNotifier`) holds a `Map>` keyed by date-only values, selected day, focused month, and loaded date range. The screen uses `table_calendar` for the month strip and a `ListView` for the daily agenda. Create/edit/delete is handled by `EventFormSheet`, a scrollable modal bottom sheet. The existing `/api/events` backend is used unchanged. + +**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, `table_calendar` package + +--- + +## Files + +### New +| File | Responsibility | +|------|---------------| +| `lib/data/models/calendar_event.dart` | `CalendarEvent` model + `fromJson` | +| `lib/data/api/events_api.dart` | `getEvents`, `createEvent`, `updateEvent`, `deleteEvent` Dio calls | +| `lib/providers/calendar_provider.dart` | `CalendarState`, `CalendarNotifier`, `calendarProvider` | +| `lib/screens/calendar/calendar_screen.dart` | Calendar screen UI (month strip + agenda) | +| `lib/screens/calendar/event_form_sheet.dart` | Create/edit modal bottom sheet | + +### Modified +| File | Change | +|------|--------| +| `lib/providers/api_client_provider.dart` | Add `eventsApiProvider` | +| `lib/app.dart` | Replace Calendar stub route with `CalendarScreen()` | +| `test/widget_test.dart` | Add `CalendarEvent.fromJson` tests | +| `pubspec.yaml` | Add `table_calendar` dependency | + +--- + +## Data Model + +### `CalendarEvent` +```dart +class CalendarEvent { + final int id; + final String title; + final DateTime startDt; + final DateTime? endDt; + final bool allDay; + final String description; + final String location; + final String color; + final String? recurrence; // raw RRULE string, e.g. "FREQ=WEEKLY" + final int? projectId; + final int? reminderMinutes; +} +``` + +`fromJson` maps: `id`, `title`, `start_dt` / `end_dt` (ISO string → `DateTime.parse`), `all_day`, `description`, `location`, `color`, `recurrence` (nullable), `project_id` (nullable), `reminder_minutes` (nullable). + +Date normalization helper — used throughout to build map keys: +```dart +DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day); +``` + +--- + +## API Layer + +### `events_api.dart` + +```dart +class EventsApi { + final Dio _dio; + const EventsApi(this._dio); + + // GET /api/events?from=&to= + Future> getEvents(DateTime from, DateTime to) async { ... } + + // POST /api/events + Future createEvent(Map payload) async { ... } + + // PATCH /api/events/{id} + Future updateEvent(int id, Map fields) async { ... } + + // DELETE /api/events/{id} + Future deleteEvent(int id) async { ... } +} +``` + +All methods catch `DioException` and rethrow via `dioToApp(e)` (same pattern as `NewsApi`). + +### `api_client_provider.dart` addition +```dart +final eventsApiProvider = Provider((ref) => + EventsApi(ref.watch(dioProvider))); +``` + +--- + +## State Management + +### `CalendarState` +```dart +class CalendarState { + final Map> eventsByDay; // keys: midnight local time + final DateTime selectedDay; + final DateTime focusedMonth; + final DateTimeRange loadedRange; +} +``` + +### `CalendarNotifier extends AsyncNotifier` + +- **`build()`**: fetches events for `[firstDayOfMonth - 1 month, lastDayOfMonth + 1 month]` for the current month; populates `eventsByDay`; sets `selectedDay` to today; sets `loadedRange` to the fetched range. + +- **`selectDay(DateTime day)`**: synchronous state update — updates `selectedDay` and `focusedMonth` to `DateTime(day.year, day.month)`. No API call. + +- **`loadMonth(DateTime month)`**: updates `focusedMonth`. If `month` is already within `loadedRange`, no-op (state update only). Otherwise fetches events for that month, merges new items into `eventsByDay`, extends `loadedRange`. + +- **`addEvent(CalendarEvent event)`**: inserts the event into `eventsByDay` under `dateOnly(event.startDt)`. Synchronous local mutation after successful API call. + +- **`updateEvent(CalendarEvent updated)`**: removes old entry by `id` from its old date bucket (scanned), inserts under `dateOnly(updated.startDt)`. Synchronous local mutation. + +- **`removeEvent(int id, DateTime date)`**: removes from `eventsByDay[dateOnly(date)]` by id. Synchronous local mutation. + +All three mutation methods accept the server-returned `CalendarEvent` — the screen calls the API first, then passes the result to the notifier. + +--- + +## Screen Behaviour + +### `CalendarScreen` (`ConsumerStatefulWidget`) + +**AppBar**: title "Calendar". + +**Month strip** (`TableCalendar`): +- Format: `CalendarFormat.month` +- Selected day highlighted with primary color +- Days with events show a dot indicator +- `onDaySelected`: calls `notifier.selectDay(day)` +- `onPageChanged`: calls `notifier.loadMonth(month)` + +**Agenda list** (`ListView.builder`): +- Items: `state.eventsByDay[dateOnly(state.selectedDay)] ?? []` +- Each `EventTile` shows: color dot, title, time string ("All day" if `allDay`, otherwise formatted start time) +- Tap → opens `EventFormSheet` in edit mode +- Empty: centered "No events" message + +**FAB** (`FloatingActionButton`): opens `EventFormSheet` in create mode with `startDt` pre-set to `selectedDay` at current time (rounded to nearest hour) + +**Initial loading**: `CircularProgressIndicator` centered. Error: message + "Retry" button calls `ref.invalidate(calendarProvider)`. + +--- + +## Event Form Sheet + +### `EventFormSheet` (shown via `showModalBottomSheet(isScrollControlled: true, useSafeArea: true)`) + +Accepts `CalendarEvent? event` (null = create mode) and `DateTime? initialDate` (used in create mode). + +**Fields:** +| Field | Widget | Notes | +|-------|--------|-------| +| Title | `TextField` | Required | +| All-day | `SwitchListTile` | Hides time pickers when on | +| Start date | `ListTile` → `showDatePicker` | | +| Start time | `ListTile` → `showTimePicker` | Hidden when all-day | +| End date | `ListTile` → `showDatePicker` | Optional, clearable | +| End time | `ListTile` → `showTimePicker` | Hidden when all-day | +| Repeat | `DropdownButton` | None / Daily / Weekly / Monthly / Yearly | +| Description | `TextField` multiline | Optional | +| Location | `TextField` | Optional | +| Color | Row of `InkWell` color chips | 6 preset colors + clear (empty string) | + +**Repeat → RRULE mapping:** +| UI value | RRULE stored | +|----------|-------------| +| None | `null` | +| Daily | `FREQ=DAILY` | +| Weekly | `FREQ=WEEKLY` | +| Monthly | `FREQ=MONTHLY` | +| Yearly | `FREQ=YEARLY` | + +Existing events with an unrecognized RRULE string (does not match the 5 patterns above) display "Custom (read-only)" and the dropdown is disabled. The raw RRULE is preserved unchanged on save. + +**Save flow:** +1. Validate title non-empty +2. Build payload map from form state +3. Call `EventsApi.createEvent` or `EventsApi.updateEvent` +4. On success: call `notifier.addEvent(result)` or `notifier.updateEvent(result)`, pop sheet +5. On error: show SnackBar "Failed to save event." + +**Delete flow (edit mode only):** +1. Show `AlertDialog` "Delete this event?" +2. On confirm: call `EventsApi.deleteEvent(event.id)` +3. On success: call `notifier.removeEvent(event.id, event.startDt)`, pop sheet +4. On error: show SnackBar "Failed to delete event." + +--- + +## What This Does NOT Include + +- Recurrence instance editing ("edit this event only" vs "all events") — backend does not support this distinction +- Reminder/notification editing — `reminder_minutes` field not exposed (backend stores it but no push notification is sent from events) +- Project association — `project_id` field not exposed in the form +- CalDAV sync trigger — available via `POST /api/events/sync` but not surfaced in this screen From 8a0837a843c340e480026cadd1695ec70ac4fc86 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:17:29 -0400 Subject: [PATCH 15/28] docs: add Android Calendar screen implementation plan --- .../2026-04-06-android-calendar-screen.md | 1235 +++++++++++++++++ 1 file changed, 1235 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-android-calendar-screen.md diff --git a/docs/superpowers/plans/2026-04-06-android-calendar-screen.md b/docs/superpowers/plans/2026-04-06-android-calendar-screen.md new file mode 100644 index 0000000..8a57fdb --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-android-calendar-screen.md @@ -0,0 +1,1235 @@ +# Android Calendar 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 Calendar screen — a `table_calendar` month strip + daily agenda with full event CRUD including a simple recurrence picker (None / Daily / Weekly / Monthly / Yearly). + +**Architecture:** `CalendarNotifier` (`AsyncNotifier`) holds `Map>` keyed by midnight-local dates, plus selected day, focused month, and loaded date range. The screen renders a `TableCalendar` month strip and a `ListView` agenda. `EventFormSheet` (Task 4) is created before `CalendarScreen` (Task 5) so imports resolve cleanly. + +**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, `table_calendar: ^3.1.2` + +--- + +## Files + +| File | Action | Responsibility | +|------|--------|---------------| +| `lib/data/models/calendar_event.dart` | Create | `CalendarEvent` model + `fromJson` + `dateOnly` helper | +| `lib/data/api/events_api.dart` | Create | `getEvents`, `createEvent`, `updateEvent`, `deleteEvent` | +| `lib/providers/api_client_provider.dart` | Modify | Add `eventsApiProvider` | +| `lib/providers/calendar_provider.dart` | Create | `CalendarState`, `CalendarNotifier`, `calendarProvider` | +| `lib/screens/calendar/event_form_sheet.dart` | Create | Create/edit/delete modal bottom sheet (no dependency on calendar_screen) | +| `lib/screens/calendar/calendar_screen.dart` | Create | Month strip + agenda screen (imports event_form_sheet) | +| `lib/app.dart` | Modify | Replace Calendar stub route with `CalendarScreen()` | +| `test/widget_test.dart` | Modify | Add `CalendarEvent.fromJson` tests | +| `pubspec.yaml` | Modify | Add `table_calendar: ^3.1.2` | + +--- + +### Task 1: `table_calendar` package + `CalendarEvent` model + tests + +**Files:** +- Modify: `pubspec.yaml` +- Create: `lib/data/models/calendar_event.dart` +- Modify: `test/widget_test.dart` + +- [ ] **Step 1: Add `table_calendar` to `pubspec.yaml`** + +Add `table_calendar: ^3.1.2` after `just_audio: ^0.9.39` in the `dependencies` section: + +```yaml + just_audio: ^0.9.39 + table_calendar: ^3.1.2 +``` + +- [ ] **Step 2: Fetch packages** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter pub get +``` + +Expected: `Resolving dependencies... Got dependencies!` + +- [ ] **Step 3: Write failing tests** + +Add this import at the top of `test/widget_test.dart` (after the existing imports): + +```dart +import 'package:fabled_app/data/models/calendar_event.dart'; +``` + +Add these test groups inside `void main() {` before the closing `}`: + +```dart + group('CalendarEvent.fromJson', () { + test('parses all fields', () { + final json = { + 'id': 10, + 'title': 'Team meeting', + 'start_dt': '2026-04-07T09:00:00+00:00', + 'end_dt': '2026-04-07T10:00:00+00:00', + 'all_day': false, + 'description': 'Weekly sync', + 'location': 'Room 4', + 'color': '#6366F1', + 'recurrence': 'FREQ=WEEKLY', + 'project_id': 3, + 'reminder_minutes': 15, + }; + final event = CalendarEvent.fromJson(json); + expect(event.id, equals(10)); + expect(event.title, equals('Team meeting')); + expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00'))); + expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00'))); + expect(event.allDay, isFalse); + expect(event.description, equals('Weekly sync')); + expect(event.location, equals('Room 4')); + expect(event.color, equals('#6366F1')); + expect(event.recurrence, equals('FREQ=WEEKLY')); + expect(event.projectId, equals(3)); + expect(event.reminderMinutes, equals(15)); + }); + + test('handles null optional fields', () { + final json = { + 'id': 11, + 'title': 'Birthday', + 'start_dt': '2026-05-01T00:00:00+00:00', + 'end_dt': null, + 'all_day': true, + 'description': '', + 'location': '', + 'color': '', + 'recurrence': null, + 'project_id': null, + 'reminder_minutes': null, + }; + final event = CalendarEvent.fromJson(json); + expect(event.endDt, isNull); + expect(event.allDay, isTrue); + expect(event.recurrence, isNull); + expect(event.projectId, isNull); + expect(event.reminderMinutes, isNull); + }); + }); + + group('dateOnly', () { + test('strips time from datetime', () { + final dt = DateTime(2026, 4, 7, 14, 30, 45); + expect(dateOnly(dt), equals(DateTime(2026, 4, 7))); + }); + }); +``` + +- [ ] **Step 4: Run tests — expect FAIL** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter test +``` + +Expected: FAIL — `CalendarEvent` and `dateOnly` not defined yet. + +- [ ] **Step 5: Create `lib/data/models/calendar_event.dart`** + +```dart +class CalendarEvent { + final int id; + final String title; + final DateTime startDt; + final DateTime? endDt; + final bool allDay; + final String description; + final String location; + final String color; + final String? recurrence; + final int? projectId; + final int? reminderMinutes; + + const CalendarEvent({ + required this.id, + required this.title, + required this.startDt, + this.endDt, + required this.allDay, + required this.description, + required this.location, + required this.color, + this.recurrence, + this.projectId, + this.reminderMinutes, + }); + + factory CalendarEvent.fromJson(Map json) => CalendarEvent( + id: json['id'] as int, + title: json['title'] as String? ?? '', + startDt: DateTime.parse(json['start_dt'] as String), + endDt: json['end_dt'] != null + ? DateTime.parse(json['end_dt'] as String) + : null, + allDay: json['all_day'] as bool? ?? false, + description: json['description'] as String? ?? '', + location: json['location'] as String? ?? '', + color: json['color'] as String? ?? '', + recurrence: json['recurrence'] as String?, + projectId: json['project_id'] as int?, + reminderMinutes: json['reminder_minutes'] as int?, + ); +} + +/// Strips time from a DateTime, returning midnight local. +/// Used as map keys in CalendarState.eventsByDay. +DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day); +``` + +- [ ] **Step 6: Run tests — expect PASS** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter test +``` + +Expected: All tests passed (22 total — 19 existing + 3 new). + +- [ ] **Step 7: Commit** + +```bash +git add pubspec.yaml pubspec.lock lib/data/models/calendar_event.dart test/widget_test.dart +git commit -m "feat: add table_calendar package and CalendarEvent model with tests" +``` + +--- + +### Task 2: Events API layer + +**Files:** +- Create: `lib/data/api/events_api.dart` +- Modify: `lib/providers/api_client_provider.dart` + +- [ ] **Step 1: Create `lib/data/api/events_api.dart`** + +```dart +import 'package:dio/dio.dart'; + +import '../models/calendar_event.dart'; +import 'api_client.dart'; + +class EventsApi { + final Dio _dio; + const EventsApi(this._dio); + + /// GET /api/events?from=&to= + Future> getEvents(DateTime from, DateTime to) async { + try { + final response = await _dio.get( + '/api/events', + queryParameters: { + 'from': from.toUtc().toIso8601String(), + 'to': to.toUtc().toIso8601String(), + }, + ); + final list = response.data as List; + return list + .map((e) => CalendarEvent.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST /api/events + Future createEvent(Map payload) async { + try { + final response = await _dio.post('/api/events', data: payload); + return CalendarEvent.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// PATCH /api/events/{id} + Future updateEvent( + int id, Map fields) async { + try { + final response = await _dio.patch('/api/events/$id', data: fields); + return CalendarEvent.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// DELETE /api/events/{id} + Future deleteEvent(int id) async { + try { + await _dio.delete('/api/events/$id'); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} +``` + +- [ ] **Step 2: Add `eventsApiProvider` to `lib/providers/api_client_provider.dart`** + +Add the import after the `news_api.dart` import line: + +```dart +import '../data/api/events_api.dart'; +``` + +Add the provider after the `newsApiProvider` block at the end of the file: + +```dart +final eventsApiProvider = Provider((ref) { + return EventsApi(ref.watch(dioProvider)); +}); +``` + +- [ ] **Step 3: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/data/api/events_api.dart lib/providers/api_client_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 4: Commit** + +```bash +git add lib/data/api/events_api.dart lib/providers/api_client_provider.dart +git commit -m "feat: add EventsApi and eventsApiProvider" +``` + +--- + +### Task 3: Calendar provider + +**Files:** +- Create: `lib/providers/calendar_provider.dart` + +- [ ] **Step 1: Create `lib/providers/calendar_provider.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/calendar_event.dart'; +import 'api_client_provider.dart'; + +// ─── CalendarState ──────────────────────────────────────────────────────────── + +class CalendarState { + final Map> eventsByDay; + final DateTime selectedDay; + final DateTime focusedMonth; + final DateTimeRange loadedRange; + + const CalendarState({ + required this.eventsByDay, + required this.selectedDay, + required this.focusedMonth, + required this.loadedRange, + }); + + CalendarState copyWith({ + Map>? eventsByDay, + DateTime? selectedDay, + DateTime? focusedMonth, + DateTimeRange? loadedRange, + }) { + return CalendarState( + eventsByDay: eventsByDay ?? this.eventsByDay, + selectedDay: selectedDay ?? this.selectedDay, + focusedMonth: focusedMonth ?? this.focusedMonth, + loadedRange: loadedRange ?? this.loadedRange, + ); + } +} + +// ─── CalendarNotifier ───────────────────────────────────────────────────────── + +final calendarProvider = + AsyncNotifierProvider(CalendarNotifier.new); + +class CalendarNotifier extends AsyncNotifier { + @override + Future build() async { + final now = DateTime.now(); + final today = dateOnly(now); + // Fetch current month ± 1 month as the initial window. + final from = DateTime(now.year, now.month - 1, 1); + final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59); + final events = await ref.watch(eventsApiProvider).getEvents(from, to); + return CalendarState( + eventsByDay: _groupByDay(events), + selectedDay: today, + focusedMonth: DateTime(now.year, now.month), + loadedRange: DateTimeRange(start: from, end: to), + ); + } + + /// Synchronously updates selectedDay and focusedMonth. No API call. + void selectDay(DateTime day) { + final current = state.value; + if (current == null) return; + state = AsyncData(current.copyWith( + selectedDay: dateOnly(day), + focusedMonth: DateTime(day.year, day.month), + )); + } + + /// Updates focusedMonth. Fetches events for [month] if not already loaded. + Future loadMonth(DateTime month) async { + final current = state.value; + if (current == null) return; + final focused = DateTime(month.year, month.month); + // Always update focusedMonth so TableCalendar shows the right page. + state = AsyncData(current.copyWith(focusedMonth: focused)); + // Skip fetch if the first day of [month] is within the already-loaded range. + final monthStart = DateTime(month.year, month.month, 1); + if (!monthStart.isBefore(current.loadedRange.start) && + !monthStart.isAfter(current.loadedRange.end)) return; + try { + final from = DateTime(month.year, month.month, 1); + final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59); + final events = await ref.read(eventsApiProvider).getEvents(from, to); + final s = state.value!; + final merged = Map>.from(s.eventsByDay); + for (final e in events) { + final key = dateOnly(e.startDt); + (merged[key] ??= []).add(e); + } + final newStart = + from.isBefore(s.loadedRange.start) ? from : s.loadedRange.start; + final newEnd = + to.isAfter(s.loadedRange.end) ? to : s.loadedRange.end; + state = AsyncData(s.copyWith( + eventsByDay: merged, + loadedRange: DateTimeRange(start: newStart, end: newEnd), + )); + } catch (_) { + // Failures are silent — already-loaded months remain visible. + } + } + + /// Inserts [event] into eventsByDay after a successful createEvent API call. + void addEvent(CalendarEvent event) { + final current = state.value; + if (current == null) return; + final key = dateOnly(event.startDt); + final updated = + Map>.from(current.eventsByDay); + (updated[key] ??= []).add(event); + state = AsyncData(current.copyWith(eventsByDay: updated)); + } + + /// Replaces the old entry for [updated.id] with [updated] after a successful + /// updateEvent API call. Scans all buckets to handle date changes. + void updateEvent(CalendarEvent updated) { + final current = state.value; + if (current == null) return; + final byDay = + Map>.from(current.eventsByDay); + for (final key in byDay.keys) { + byDay[key] = byDay[key]!.where((e) => e.id != updated.id).toList(); + } + final newKey = dateOnly(updated.startDt); + (byDay[newKey] ??= []).add(updated); + state = AsyncData(current.copyWith(eventsByDay: byDay)); + } + + /// Removes event [id] from [date]'s bucket after a successful deleteEvent + /// API call. + void removeEvent(int id, DateTime date) { + final current = state.value; + if (current == null) return; + final key = dateOnly(date); + final byDay = + Map>.from(current.eventsByDay); + byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList(); + state = AsyncData(current.copyWith(eventsByDay: byDay)); + } +} + +Map> _groupByDay(List events) { + final byDay = >{}; + for (final e in events) { + final key = dateOnly(e.startDt); + (byDay[key] ??= []).add(e); + } + return byDay; +} +``` + +- [ ] **Step 2: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/providers/calendar_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/providers/calendar_provider.dart +git commit -m "feat: add CalendarNotifier and calendarProvider" +``` + +--- + +### Task 4: Event form sheet + +**Files:** +- Create: `lib/screens/calendar/event_form_sheet.dart` + +This file has no dependency on `calendar_screen.dart` and is created first so Task 5 imports resolve cleanly. + +- [ ] **Step 1: Create `lib/screens/calendar/event_form_sheet.dart`** + +Create the directory `lib/screens/calendar/` if needed, then write this file: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/models/calendar_event.dart'; +import '../../providers/api_client_provider.dart'; +import '../../providers/calendar_provider.dart'; + +class EventFormSheet extends ConsumerStatefulWidget { + /// null = create mode; non-null = edit mode. + final CalendarEvent? event; + + /// Pre-fills start date in create mode. Ignored in edit mode. + final DateTime? initialDate; + + final CalendarNotifier notifier; + + const EventFormSheet({ + super.key, + required this.event, + required this.initialDate, + required this.notifier, + }); + + @override + ConsumerState createState() => _EventFormSheetState(); +} + +class _EventFormSheetState extends ConsumerState { + late final TextEditingController _titleCtrl; + late final TextEditingController _descCtrl; + late final TextEditingController _locationCtrl; + + late DateTime _startDt; + DateTime? _endDt; + late bool _allDay; + String? _recurrence; // null = None; one of the FREQ= strings otherwise + String _color = ''; + bool _saving = false; + + bool get _isCreate => widget.event == null; + + /// Maps RRULE string (or null) to display label for the dropdown. + static const Map _knownRrules = { + null: 'None', + 'FREQ=DAILY': 'Daily', + 'FREQ=WEEKLY': 'Weekly', + 'FREQ=MONTHLY': 'Monthly', + 'FREQ=YEARLY': 'Yearly', + }; + + /// True when editing an event whose RRULE is not one of the 5 known patterns. + bool get _isCustomRrule => + widget.event?.recurrence != null && + !_knownRrules.containsKey(widget.event!.recurrence); + + static const List _presetColors = [ + '#EF4444', + '#F59E0B', + '#10B981', + '#6366F1', + '#8B5CF6', + '#EC4899', + ]; + + @override + void initState() { + super.initState(); + final e = widget.event; + _titleCtrl = TextEditingController(text: e?.title ?? ''); + _descCtrl = TextEditingController(text: e?.description ?? ''); + _locationCtrl = TextEditingController(text: e?.location ?? ''); + + if (e != null) { + _startDt = e.startDt.toLocal(); + _endDt = e.endDt?.toLocal(); + _allDay = e.allDay; + // Custom RRULEs are displayed as read-only; leave _recurrence = null. + _recurrence = _isCustomRrule ? null : e.recurrence; + _color = e.color; + } else { + final base = widget.initialDate ?? DateTime.now(); + final now = DateTime.now(); + final hour = now.minute >= 30 ? (now.hour + 1) % 24 : now.hour; + _startDt = DateTime(base.year, base.month, base.day, hour); + _allDay = false; + _recurrence = null; + } + } + + @override + void dispose() { + _titleCtrl.dispose(); + _descCtrl.dispose(); + _locationCtrl.dispose(); + super.dispose(); + } + + // ── Save ────────────────────────────────────────────────────────────────── + + Future _save() async { + if (_titleCtrl.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Title is required.')), + ); + return; + } + setState(() => _saving = true); + try { + // Preserve unrecognised RRULEs unchanged on save. + final rrule = _isCustomRrule ? widget.event!.recurrence : _recurrence; + final payload = { + 'title': _titleCtrl.text.trim(), + 'start_dt': _startDt.toUtc().toIso8601String(), + if (_endDt != null) 'end_dt': _endDt!.toUtc().toIso8601String(), + 'all_day': _allDay, + 'description': _descCtrl.text.trim(), + 'location': _locationCtrl.text.trim(), + 'color': _color, + 'recurrence': rrule, + }; + final api = ref.read(eventsApiProvider); + if (_isCreate) { + final created = await api.createEvent(payload); + widget.notifier.addEvent(created); + } else { + final updated = await api.updateEvent(widget.event!.id, payload); + widget.notifier.updateEvent(updated); + } + if (mounted) Navigator.of(context).pop(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to save event.')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + // ── Delete ──────────────────────────────────────────────────────────────── + + Future _delete() async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete this event?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + 'Delete', + style: + TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _saving = true); + try { + await ref.read(eventsApiProvider).deleteEvent(widget.event!.id); + widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt); + if (mounted) Navigator.of(context).pop(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to delete event.')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + // ── Date/time pickers ───────────────────────────────────────────────────── + + Future _pickStartDate() async { + final d = await showDatePicker( + context: context, + initialDate: _startDt, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (d != null) { + setState(() { + _startDt = DateTime( + d.year, d.month, d.day, _startDt.hour, _startDt.minute); + }); + } + } + + Future _pickStartTime() async { + final t = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_startDt), + ); + if (t != null) { + setState(() { + _startDt = DateTime( + _startDt.year, _startDt.month, _startDt.day, t.hour, t.minute); + }); + } + } + + Future _pickEndDate() async { + final d = await showDatePicker( + context: context, + initialDate: _endDt ?? _startDt, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (d != null) { + setState(() { + final prev = _endDt ?? _startDt; + _endDt = + DateTime(d.year, d.month, d.day, prev.hour, prev.minute); + }); + } + } + + Future _pickEndTime() async { + final t = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_endDt ?? _startDt), + ); + if (t != null) { + setState(() { + final prev = _endDt ?? _startDt; + _endDt = DateTime( + prev.year, prev.month, prev.day, t.hour, t.minute); + }); + } + } + + // ── Formatting helpers ──────────────────────────────────────────────────── + + String _fmtDate(DateTime dt) { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${months[dt.month - 1]} ${dt.day}, ${dt.year}'; + } + + String _fmtTime(DateTime dt) => + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Text( + _isCreate ? 'New Event' : 'Edit Event', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + if (!_isCreate) + IconButton( + icon: const Icon(Icons.delete_outline), + color: Theme.of(context).colorScheme.error, + onPressed: _saving ? null : _delete, + ), + ], + ), + const SizedBox(height: 16), + + // Title + TextField( + controller: _titleCtrl, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 12), + + // All-day toggle + SwitchListTile( + title: const Text('All day'), + value: _allDay, + onChanged: (v) => setState(() => _allDay = v), + contentPadding: EdgeInsets.zero, + ), + + // Start date + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.calendar_today_outlined), + title: Text(_fmtDate(_startDt)), + subtitle: const Text('Start date'), + onTap: _pickStartDate, + ), + + // Start time (hidden when all-day) + if (!_allDay) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.access_time_outlined), + title: Text(_fmtTime(_startDt)), + subtitle: const Text('Start time'), + onTap: _pickStartTime, + ), + + // End date + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event_outlined), + title: Text( + _endDt != null ? _fmtDate(_endDt!) : 'No end date'), + subtitle: const Text('End date'), + onTap: _pickEndDate, + trailing: _endDt != null + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => setState(() => _endDt = null), + ) + : null, + ), + + // End time (hidden when all-day or no end date) + if (!_allDay && _endDt != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.access_time_outlined), + title: Text(_fmtTime(_endDt!)), + subtitle: const Text('End time'), + onTap: _pickEndTime, + ), + + const SizedBox(height: 8), + + // Repeat — show read-only tile for custom RRULEs + if (_isCustomRrule) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.repeat_outlined), + title: const Text('Custom (read-only)'), + subtitle: Text(widget.event!.recurrence ?? ''), + ) + else + Row( + children: [ + const Icon(Icons.repeat_outlined), + const SizedBox(width: 16), + DropdownButton( + value: _recurrence, + underline: const SizedBox.shrink(), + items: _knownRrules.entries + .map((entry) => DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + )) + .toList(), + onChanged: (v) => setState(() => _recurrence = v), + ), + ], + ), + + const SizedBox(height: 12), + + // Description + TextField( + controller: _descCtrl, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + ), + maxLines: 3, + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 12), + + // Location + TextField( + controller: _locationCtrl, + decoration: const InputDecoration( + labelText: 'Location', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + // Color chips + Text('Color', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 8), + Row( + children: [ + // "No color" chip + GestureDetector( + onTap: () => setState(() => _color = ''), + child: Container( + width: 32, + height: 32, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: _color.isEmpty + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + width: _color.isEmpty ? 2 : 1, + ), + ), + child: const Icon(Icons.block, size: 16), + ), + ), + ..._presetColors.map((hex) { + final c = + Color(int.parse(hex.replaceFirst('#', '0xFF'))); + return GestureDetector( + onTap: () => setState(() => _color = hex), + child: Container( + width: 32, + height: 32, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: _color == hex + ? Border.all( + color: Theme.of(context) + .colorScheme + .primary, + width: 2, + ) + : null, + ), + ), + ); + }), + ], + ), + + const SizedBox(height: 24), + + // Save button + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isCreate ? 'Create' : 'Save'), + ), + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/screens/calendar/event_form_sheet.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/calendar/event_form_sheet.dart +git commit -m "feat: add EventFormSheet with CRUD, recurrence picker, and color chips" +``` + +--- + +### Task 5: Calendar screen + +**Files:** +- Create: `lib/screens/calendar/calendar_screen.dart` + +`EventFormSheet` from Task 4 already exists — this file imports it cleanly. + +- [ ] **Step 1: Create `lib/screens/calendar/calendar_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:table_calendar/table_calendar.dart'; + +import '../../data/models/calendar_event.dart'; +import '../../providers/calendar_provider.dart'; +import 'event_form_sheet.dart'; + +class CalendarScreen extends ConsumerWidget { + const CalendarScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final calAsync = ref.watch(calendarProvider); + final notifier = ref.read(calendarProvider.notifier); + + return Scaffold( + appBar: AppBar(title: const Text('Calendar')), + body: calAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Could not load calendar.'), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: () => ref.invalidate(calendarProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (cal) => Column( + children: [ + TableCalendar( + firstDay: DateTime(2020), + lastDay: DateTime(2030), + focusedDay: cal.focusedMonth, + selectedDayPredicate: (day) => isSameDay(day, cal.selectedDay), + eventLoader: (day) => + cal.eventsByDay[dateOnly(day)] ?? [], + calendarFormat: CalendarFormat.month, + headerStyle: const HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + ), + calendarStyle: CalendarStyle( + selectedDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + todayDecoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + markerDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + shape: BoxShape.circle, + ), + ), + onDaySelected: (selected, _) => notifier.selectDay(selected), + onPageChanged: (focused) => notifier.loadMonth(focused), + ), + const Divider(height: 1), + Expanded( + child: _AgendaList( + events: + cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [], + notifier: notifier, + ), + ), + ], + ), + ), + floatingActionButton: calAsync.hasValue + ? FloatingActionButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => EventFormSheet( + event: null, + initialDate: calAsync.value!.selectedDay, + notifier: notifier, + ), + ), + child: const Icon(Icons.add), + ) + : null, + ); + } +} + +// ── Agenda list ─────────────────────────────────────────────────────────────── + +class _AgendaList extends StatelessWidget { + final List events; + final CalendarNotifier notifier; + + const _AgendaList({required this.events, required this.notifier}); + + @override + Widget build(BuildContext context) { + if (events.isEmpty) { + return const Center(child: Text('No events')); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: events.length, + itemBuilder: (_, i) => + _EventTile(event: events[i], notifier: notifier), + ); + } +} + +// ── Event tile ──────────────────────────────────────────────────────────────── + +class _EventTile extends StatelessWidget { + final CalendarEvent event; + final CalendarNotifier notifier; + + const _EventTile({required this.event, required this.notifier}); + + Color _dotColor(BuildContext context) { + if (event.color.isEmpty) return Theme.of(context).colorScheme.primary; + try { + return Color(int.parse(event.color.replaceFirst('#', '0xFF'))); + } catch (_) { + return Theme.of(context).colorScheme.primary; + } + } + + String _timeLabel() { + if (event.allDay) return 'All day'; + final h = event.startDt.hour.toString().padLeft(2, '0'); + final m = event.startDt.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + radius: 6, + backgroundColor: _dotColor(context), + ), + title: Text(event.title), + subtitle: Text(_timeLabel()), + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => EventFormSheet( + event: event, + initialDate: null, + notifier: notifier, + ), + ), + ); + } +} +``` + +- [ ] **Step 2: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/screens/calendar/calendar_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/calendar/calendar_screen.dart +git commit -m "feat: add CalendarScreen with month strip and agenda list" +``` + +--- + +### Task 6: Wire route and final verification + +**Files:** +- Modify: `lib/app.dart` + +- [ ] **Step 1: Add import to `lib/app.dart`** + +Add this import alongside the other screen imports: + +```dart +import 'screens/calendar/calendar_screen.dart'; +``` + +- [ ] **Step 2: Replace the Calendar stub route in `lib/app.dart`** + +Find: + +```dart + GoRoute( + path: Routes.calendar, + builder: (_, _) => Scaffold( + appBar: AppBar(title: const Text('Calendar')), + body: const Center(child: Text('Calendar — coming soon')), + ), + ), +``` + +Replace with: + +```dart + GoRoute( + path: Routes.calendar, + builder: (_, _) => const CalendarScreen(), + ), +``` + +- [ ] **Step 3: Full analyze and test** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze +flutter test +``` + +Expected: `No issues found!` and all tests passed (22 total). + +- [ ] **Step 4: Commit** + +```bash +git add lib/app.dart +git commit -m "feat: wire Calendar route to CalendarScreen" +``` From b56c0fc02d59bc3e017478dd52eb77244d20d966 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:25:52 -0400 Subject: [PATCH 16/28] feat: add table_calendar package and CalendarEvent model with tests Co-Authored-By: Claude Sonnet 4.6 --- lib/data/models/calendar_event.dart | 47 ++++++++++++++++++++++ pubspec.lock | 24 ++++++++++++ pubspec.yaml | 1 + test/widget_test.dart | 60 +++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 lib/data/models/calendar_event.dart diff --git a/lib/data/models/calendar_event.dart b/lib/data/models/calendar_event.dart new file mode 100644 index 0000000..827a2ff --- /dev/null +++ b/lib/data/models/calendar_event.dart @@ -0,0 +1,47 @@ +class CalendarEvent { + final int id; + final String title; + final DateTime startDt; + final DateTime? endDt; + final bool allDay; + final String description; + final String location; + final String color; + final String? recurrence; + final int? projectId; + final int? reminderMinutes; + + const CalendarEvent({ + required this.id, + required this.title, + required this.startDt, + this.endDt, + required this.allDay, + required this.description, + required this.location, + required this.color, + this.recurrence, + this.projectId, + this.reminderMinutes, + }); + + factory CalendarEvent.fromJson(Map json) => CalendarEvent( + id: json['id'] as int, + title: json['title'] as String? ?? '', + startDt: DateTime.parse(json['start_dt'] as String), + endDt: json['end_dt'] != null + ? DateTime.parse(json['end_dt'] as String) + : null, + allDay: json['all_day'] as bool? ?? false, + description: json['description'] as String? ?? '', + location: json['location'] as String? ?? '', + color: json['color'] as String? ?? '', + recurrence: json['recurrence'] as String?, + projectId: json['project_id'] as int?, + reminderMinutes: json['reminder_minutes'] as int?, + ); +} + +/// Strips time from a DateTime, returning midnight local. +/// Used as map keys in CalendarState.eventsByDay. +DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day); diff --git a/pubspec.lock b/pubspec.lock index 44961eb..5f15306 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -408,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -952,6 +960,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -1021,6 +1037,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.0" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 064c763..b76d5f8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: url_launcher: ^6.3.1 record: ^5.0.0 just_audio: ^0.9.39 + table_calendar: ^3.1.2 dependency_overrides: record_linux: ^1.3.0 diff --git a/test/widget_test.dart b/test/widget_test.dart index 3820264..df2f476 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:fabled_app/data/models/calendar_event.dart'; import 'package:fabled_app/data/api/voice_api.dart'; import 'package:fabled_app/providers/voice_provider.dart'; import 'package:fabled_app/data/models/knowledge_item.dart'; @@ -190,6 +191,65 @@ void main() { }); }); + group('CalendarEvent.fromJson', () { + test('parses all fields', () { + final json = { + 'id': 10, + 'title': 'Team meeting', + 'start_dt': '2026-04-07T09:00:00+00:00', + 'end_dt': '2026-04-07T10:00:00+00:00', + 'all_day': false, + 'description': 'Weekly sync', + 'location': 'Room 4', + 'color': '#6366F1', + 'recurrence': 'FREQ=WEEKLY', + 'project_id': 3, + 'reminder_minutes': 15, + }; + final event = CalendarEvent.fromJson(json); + expect(event.id, equals(10)); + expect(event.title, equals('Team meeting')); + expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00'))); + expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00'))); + expect(event.allDay, isFalse); + expect(event.description, equals('Weekly sync')); + expect(event.location, equals('Room 4')); + expect(event.color, equals('#6366F1')); + expect(event.recurrence, equals('FREQ=WEEKLY')); + expect(event.projectId, equals(3)); + expect(event.reminderMinutes, equals(15)); + }); + + test('handles null optional fields', () { + final json = { + 'id': 11, + 'title': 'Birthday', + 'start_dt': '2026-05-01T00:00:00+00:00', + 'end_dt': null, + 'all_day': true, + 'description': '', + 'location': '', + 'color': '', + 'recurrence': null, + 'project_id': null, + 'reminder_minutes': null, + }; + final event = CalendarEvent.fromJson(json); + expect(event.endDt, isNull); + expect(event.allDay, isTrue); + expect(event.recurrence, isNull); + expect(event.projectId, isNull); + expect(event.reminderMinutes, isNull); + }); + }); + + group('dateOnly', () { + test('strips time from datetime', () { + final dt = DateTime(2026, 4, 7, 14, 30, 45); + expect(dateOnly(dt), equals(DateTime(2026, 4, 7))); + }); + }); + group('BriefingFeed.fromJson', () { test('parses all fields', () { final json = { From 776b39487451e737f76e3fbafdccef4409cf57ed Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:27:11 -0400 Subject: [PATCH 17/28] feat: add EventsApi and eventsApiProvider --- lib/data/api/events_api.dart | 58 ++++++++++++++++++++++++++ lib/providers/api_client_provider.dart | 5 +++ 2 files changed, 63 insertions(+) create mode 100644 lib/data/api/events_api.dart diff --git a/lib/data/api/events_api.dart b/lib/data/api/events_api.dart new file mode 100644 index 0000000..90a50e8 --- /dev/null +++ b/lib/data/api/events_api.dart @@ -0,0 +1,58 @@ +import 'package:dio/dio.dart'; + +import '../models/calendar_event.dart'; +import 'api_client.dart'; + +class EventsApi { + final Dio _dio; + const EventsApi(this._dio); + + /// GET /api/events?from={iso}&to={iso} + Future> getEvents(DateTime from, DateTime to) async { + try { + final response = await _dio.get( + '/api/events', + queryParameters: { + 'from': from.toUtc().toIso8601String(), + 'to': to.toUtc().toIso8601String(), + }, + ); + final list = response.data as List; + return list + .map((e) => CalendarEvent.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST /api/events + Future createEvent(Map payload) async { + try { + final response = await _dio.post('/api/events', data: payload); + return CalendarEvent.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// PATCH /api/events/{id} + Future updateEvent( + int id, Map fields) async { + try { + final response = await _dio.patch('/api/events/$id', data: fields); + return CalendarEvent.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// DELETE /api/events/{id} + Future deleteEvent(int id) async { + try { + await _dio.delete('/api/events/$id'); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index be162ad..e84d6f5 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -9,6 +9,7 @@ import '../data/api/chat_api.dart'; import '../data/api/knowledge_api.dart'; import '../data/api/voice_api.dart'; import '../data/api/milestones_api.dart'; +import '../data/api/events_api.dart'; import '../data/api/news_api.dart'; import '../data/api/notes_api.dart'; import '../data/api/projects_api.dart'; @@ -115,3 +116,7 @@ final voiceRepositoryProvider = Provider((ref) { final newsApiProvider = Provider((ref) { return NewsApi(ref.watch(dioProvider)); }); + +final eventsApiProvider = Provider((ref) { + return EventsApi(ref.watch(dioProvider)); +}); From c4dca6d4ed153f9b39c5c43911e094b556fef5ef Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:28:53 -0400 Subject: [PATCH 18/28] feat: add CalendarNotifier and calendarProvider --- lib/providers/calendar_provider.dart | 151 +++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 lib/providers/calendar_provider.dart diff --git a/lib/providers/calendar_provider.dart b/lib/providers/calendar_provider.dart new file mode 100644 index 0000000..c3c8951 --- /dev/null +++ b/lib/providers/calendar_provider.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/calendar_event.dart'; +import 'api_client_provider.dart'; + +// ─── CalendarState ──────────────────────────────────────────────────────────── + +class CalendarState { + final Map> eventsByDay; + final DateTime selectedDay; + final DateTime focusedMonth; + final DateTimeRange loadedRange; + + const CalendarState({ + required this.eventsByDay, + required this.selectedDay, + required this.focusedMonth, + required this.loadedRange, + }); + + CalendarState copyWith({ + Map>? eventsByDay, + DateTime? selectedDay, + DateTime? focusedMonth, + DateTimeRange? loadedRange, + }) { + return CalendarState( + eventsByDay: eventsByDay ?? this.eventsByDay, + selectedDay: selectedDay ?? this.selectedDay, + focusedMonth: focusedMonth ?? this.focusedMonth, + loadedRange: loadedRange ?? this.loadedRange, + ); + } +} + +// ─── CalendarNotifier ───────────────────────────────────────────────────────── + +final calendarProvider = + AsyncNotifierProvider(CalendarNotifier.new); + +class CalendarNotifier extends AsyncNotifier { + @override + Future build() async { + final now = DateTime.now(); + final today = dateOnly(now); + // Fetch current month ± 1 month as the initial window. + final from = DateTime(now.year, now.month - 1, 1); + final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59); + final events = await ref.watch(eventsApiProvider).getEvents(from, to); + return CalendarState( + eventsByDay: _groupByDay(events), + selectedDay: today, + focusedMonth: DateTime(now.year, now.month), + loadedRange: DateTimeRange(start: from, end: to), + ); + } + + /// Synchronously updates selectedDay and focusedMonth. No API call. + void selectDay(DateTime day) { + final current = state.value; + if (current == null) return; + state = AsyncData(current.copyWith( + selectedDay: dateOnly(day), + focusedMonth: DateTime(day.year, day.month), + )); + } + + /// Updates focusedMonth. Fetches events for [month] if not already loaded. + Future loadMonth(DateTime month) async { + final current = state.value; + if (current == null) return; + final focused = DateTime(month.year, month.month); + // Always update focusedMonth so TableCalendar shows the right page. + state = AsyncData(current.copyWith(focusedMonth: focused)); + // Skip fetch if the first day of [month] is within the already-loaded range. + final monthStart = DateTime(month.year, month.month, 1); + if (!monthStart.isBefore(current.loadedRange.start) && + !monthStart.isAfter(current.loadedRange.end)) { + return; + } + try { + final from = DateTime(month.year, month.month, 1); + final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59); + final events = await ref.read(eventsApiProvider).getEvents(from, to); + final s = state.value!; + final merged = Map>.from(s.eventsByDay); + for (final e in events) { + final key = dateOnly(e.startDt); + (merged[key] ??= []).add(e); + } + final newStart = + from.isBefore(s.loadedRange.start) ? from : s.loadedRange.start; + final newEnd = + to.isAfter(s.loadedRange.end) ? to : s.loadedRange.end; + state = AsyncData(s.copyWith( + eventsByDay: merged, + loadedRange: DateTimeRange(start: newStart, end: newEnd), + )); + } catch (_) { + // Failures are silent — already-loaded months remain visible. + } + } + + /// Inserts [event] into eventsByDay after a successful createEvent API call. + void addEvent(CalendarEvent event) { + final current = state.value; + if (current == null) return; + final key = dateOnly(event.startDt); + final updated = + Map>.from(current.eventsByDay); + (updated[key] ??= []).add(event); + state = AsyncData(current.copyWith(eventsByDay: updated)); + } + + /// Replaces the old entry for [updated.id] with [updated] after a successful + /// updateEvent API call. Scans all buckets to handle date changes. + void updateEvent(CalendarEvent updated) { + final current = state.value; + if (current == null) return; + final byDay = + Map>.from(current.eventsByDay); + for (final key in byDay.keys) { + byDay[key] = byDay[key]!.where((e) => e.id != updated.id).toList(); + } + final newKey = dateOnly(updated.startDt); + (byDay[newKey] ??= []).add(updated); + state = AsyncData(current.copyWith(eventsByDay: byDay)); + } + + /// Removes event [id] from [date]'s bucket after a successful deleteEvent + /// API call. + void removeEvent(int id, DateTime date) { + final current = state.value; + if (current == null) return; + final key = dateOnly(date); + final byDay = + Map>.from(current.eventsByDay); + byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList(); + state = AsyncData(current.copyWith(eventsByDay: byDay)); + } +} + +Map> _groupByDay(List events) { + final byDay = >{}; + for (final e in events) { + final key = dateOnly(e.startDt); + (byDay[key] ??= []).add(e); + } + return byDay; +} From 334882520cd36cedda66ad0693b665a5810cceed Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:31:08 -0400 Subject: [PATCH 19/28] feat: add EventFormSheet with CRUD, recurrence picker, and color chips Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/calendar/event_form_sheet.dart | 473 +++++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 lib/screens/calendar/event_form_sheet.dart diff --git a/lib/screens/calendar/event_form_sheet.dart b/lib/screens/calendar/event_form_sheet.dart new file mode 100644 index 0000000..4197543 --- /dev/null +++ b/lib/screens/calendar/event_form_sheet.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/models/calendar_event.dart'; +import '../../providers/api_client_provider.dart'; +import '../../providers/calendar_provider.dart'; + +class EventFormSheet extends ConsumerStatefulWidget { + /// null = create mode; non-null = edit mode. + final CalendarEvent? event; + + /// Pre-fills start date in create mode. Ignored in edit mode. + final DateTime? initialDate; + + final CalendarNotifier notifier; + + const EventFormSheet({ + super.key, + required this.event, + required this.initialDate, + required this.notifier, + }); + + @override + ConsumerState createState() => _EventFormSheetState(); +} + +class _EventFormSheetState extends ConsumerState { + late final TextEditingController _titleCtrl; + late final TextEditingController _descCtrl; + late final TextEditingController _locationCtrl; + + late DateTime _startDt; + DateTime? _endDt; + late bool _allDay; + String? _recurrence; // null = None; one of the FREQ= strings otherwise + String _color = ''; + bool _saving = false; + + bool get _isCreate => widget.event == null; + + /// Maps RRULE string (or null) to display label for the dropdown. + static const Map _knownRrules = { + null: 'None', + 'FREQ=DAILY': 'Daily', + 'FREQ=WEEKLY': 'Weekly', + 'FREQ=MONTHLY': 'Monthly', + 'FREQ=YEARLY': 'Yearly', + }; + + /// True when editing an event whose RRULE is not one of the 5 known patterns. + bool get _isCustomRrule => + widget.event?.recurrence != null && + !_knownRrules.containsKey(widget.event!.recurrence); + + static const List _presetColors = [ + '#EF4444', + '#F59E0B', + '#10B981', + '#6366F1', + '#8B5CF6', + '#EC4899', + ]; + + @override + void initState() { + super.initState(); + final e = widget.event; + _titleCtrl = TextEditingController(text: e?.title ?? ''); + _descCtrl = TextEditingController(text: e?.description ?? ''); + _locationCtrl = TextEditingController(text: e?.location ?? ''); + + if (e != null) { + _startDt = e.startDt.toLocal(); + _endDt = e.endDt?.toLocal(); + _allDay = e.allDay; + // Custom RRULEs are displayed as read-only; leave _recurrence = null. + _recurrence = _isCustomRrule ? null : e.recurrence; + _color = e.color; + } else { + final base = widget.initialDate ?? DateTime.now(); + final now = DateTime.now(); + final hour = now.minute >= 30 ? (now.hour + 1) % 24 : now.hour; + _startDt = DateTime(base.year, base.month, base.day, hour); + _allDay = false; + _recurrence = null; + } + } + + @override + void dispose() { + _titleCtrl.dispose(); + _descCtrl.dispose(); + _locationCtrl.dispose(); + super.dispose(); + } + + // ── Save ────────────────────────────────────────────────────────────────── + + Future _save() async { + if (_titleCtrl.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Title is required.')), + ); + return; + } + setState(() => _saving = true); + try { + // Preserve unrecognised RRULEs unchanged on save. + final rrule = _isCustomRrule ? widget.event!.recurrence : _recurrence; + final payload = { + 'title': _titleCtrl.text.trim(), + 'start_dt': _startDt.toUtc().toIso8601String(), + if (_endDt != null) 'end_dt': _endDt!.toUtc().toIso8601String(), + 'all_day': _allDay, + 'description': _descCtrl.text.trim(), + 'location': _locationCtrl.text.trim(), + 'color': _color, + 'recurrence': rrule, + }; + final api = ref.read(eventsApiProvider); + if (_isCreate) { + final created = await api.createEvent(payload); + widget.notifier.addEvent(created); + } else { + final updated = await api.updateEvent(widget.event!.id, payload); + widget.notifier.updateEvent(updated); + } + if (mounted) Navigator.of(context).pop(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to save event.')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + // ── Delete ──────────────────────────────────────────────────────────────── + + Future _delete() async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete this event?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + 'Delete', + style: + TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _saving = true); + try { + await ref.read(eventsApiProvider).deleteEvent(widget.event!.id); + widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt); + if (mounted) Navigator.of(context).pop(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to delete event.')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + // ── Date/time pickers ───────────────────────────────────────────────────── + + Future _pickStartDate() async { + final d = await showDatePicker( + context: context, + initialDate: _startDt, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (d != null) { + setState(() { + _startDt = DateTime( + d.year, d.month, d.day, _startDt.hour, _startDt.minute); + }); + } + } + + Future _pickStartTime() async { + final t = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_startDt), + ); + if (t != null) { + setState(() { + _startDt = DateTime( + _startDt.year, _startDt.month, _startDt.day, t.hour, t.minute); + }); + } + } + + Future _pickEndDate() async { + final d = await showDatePicker( + context: context, + initialDate: _endDt ?? _startDt, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (d != null) { + setState(() { + final prev = _endDt ?? _startDt; + _endDt = + DateTime(d.year, d.month, d.day, prev.hour, prev.minute); + }); + } + } + + Future _pickEndTime() async { + final t = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_endDt ?? _startDt), + ); + if (t != null) { + setState(() { + final prev = _endDt ?? _startDt; + _endDt = DateTime( + prev.year, prev.month, prev.day, t.hour, t.minute); + }); + } + } + + // ── Formatting helpers ──────────────────────────────────────────────────── + + String _fmtDate(DateTime dt) { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${months[dt.month - 1]} ${dt.day}, ${dt.year}'; + } + + String _fmtTime(DateTime dt) => + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Text( + _isCreate ? 'New Event' : 'Edit Event', + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + if (!_isCreate) + IconButton( + icon: const Icon(Icons.delete_outline), + color: Theme.of(context).colorScheme.error, + onPressed: _saving ? null : _delete, + ), + ], + ), + const SizedBox(height: 16), + + // Title + TextField( + controller: _titleCtrl, + decoration: const InputDecoration( + labelText: 'Title', + border: OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 12), + + // All-day toggle + SwitchListTile( + title: const Text('All day'), + value: _allDay, + onChanged: (v) => setState(() => _allDay = v), + contentPadding: EdgeInsets.zero, + ), + + // Start date + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.calendar_today_outlined), + title: Text(_fmtDate(_startDt)), + subtitle: const Text('Start date'), + onTap: _pickStartDate, + ), + + // Start time (hidden when all-day) + if (!_allDay) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.access_time_outlined), + title: Text(_fmtTime(_startDt)), + subtitle: const Text('Start time'), + onTap: _pickStartTime, + ), + + // End date + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event_outlined), + title: Text( + _endDt != null ? _fmtDate(_endDt!) : 'No end date'), + subtitle: const Text('End date'), + onTap: _pickEndDate, + trailing: _endDt != null + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => setState(() => _endDt = null), + ) + : null, + ), + + // End time (hidden when all-day or no end date) + if (!_allDay && _endDt != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.access_time_outlined), + title: Text(_fmtTime(_endDt!)), + subtitle: const Text('End time'), + onTap: _pickEndTime, + ), + + const SizedBox(height: 8), + + // Repeat — show read-only tile for custom RRULEs + if (_isCustomRrule) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.repeat_outlined), + title: const Text('Custom (read-only)'), + subtitle: Text(widget.event!.recurrence ?? ''), + ) + else + Row( + children: [ + const Icon(Icons.repeat_outlined), + const SizedBox(width: 16), + DropdownButton( + value: _recurrence, + underline: const SizedBox.shrink(), + items: _knownRrules.entries + .map((entry) => DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + )) + .toList(), + onChanged: (v) => setState(() => _recurrence = v), + ), + ], + ), + + const SizedBox(height: 12), + + // Description + TextField( + controller: _descCtrl, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + ), + maxLines: 3, + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 12), + + // Location + TextField( + controller: _locationCtrl, + decoration: const InputDecoration( + labelText: 'Location', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + + // Color chips + Text('Color', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 8), + Row( + children: [ + // "No color" chip + GestureDetector( + onTap: () => setState(() => _color = ''), + child: Container( + width: 32, + height: 32, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: _color.isEmpty + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + width: _color.isEmpty ? 2 : 1, + ), + ), + child: const Icon(Icons.block, size: 16), + ), + ), + ..._presetColors.map((hex) { + final c = + Color(int.parse(hex.replaceFirst('#', '0xFF'))); + return GestureDetector( + onTap: () => setState(() => _color = hex), + child: Container( + width: 32, + height: 32, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: _color == hex + ? Border.all( + color: Theme.of(context) + .colorScheme + .primary, + width: 2, + ) + : null, + ), + ), + ); + }), + ], + ), + + const SizedBox(height: 24), + + // Save button + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isCreate ? 'Create' : 'Save'), + ), + ), + ], + ), + ), + ); + } +} From 5b639dbd4c20b5a6e0a6169c74eb1eff42bcda78 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:32:13 -0400 Subject: [PATCH 20/28] feat: add CalendarScreen with month strip and agenda list --- lib/screens/calendar/calendar_screen.dart | 165 ++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 lib/screens/calendar/calendar_screen.dart diff --git a/lib/screens/calendar/calendar_screen.dart b/lib/screens/calendar/calendar_screen.dart new file mode 100644 index 0000000..68751a5 --- /dev/null +++ b/lib/screens/calendar/calendar_screen.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:table_calendar/table_calendar.dart'; + +import '../../data/models/calendar_event.dart'; +import '../../providers/calendar_provider.dart'; +import 'event_form_sheet.dart'; + +class CalendarScreen extends ConsumerWidget { + const CalendarScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final calAsync = ref.watch(calendarProvider); + final notifier = ref.read(calendarProvider.notifier); + + return Scaffold( + appBar: AppBar(title: const Text('Calendar')), + body: calAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Could not load calendar.'), + const SizedBox(height: 12), + FilledButton.tonal( + onPressed: () => ref.invalidate(calendarProvider), + child: const Text('Retry'), + ), + ], + ), + ), + data: (cal) => Column( + children: [ + TableCalendar( + firstDay: DateTime(2020), + lastDay: DateTime(2030), + focusedDay: cal.focusedMonth, + selectedDayPredicate: (day) => isSameDay(day, cal.selectedDay), + eventLoader: (day) => + cal.eventsByDay[dateOnly(day)] ?? [], + calendarFormat: CalendarFormat.month, + headerStyle: const HeaderStyle( + formatButtonVisible: false, + titleCentered: true, + ), + calendarStyle: CalendarStyle( + selectedDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + todayDecoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + markerDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + shape: BoxShape.circle, + ), + ), + onDaySelected: (selected, _) => notifier.selectDay(selected), + onPageChanged: (focused) => notifier.loadMonth(focused), + ), + const Divider(height: 1), + Expanded( + child: _AgendaList( + events: + cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [], + notifier: notifier, + ), + ), + ], + ), + ), + floatingActionButton: calAsync.hasValue + ? FloatingActionButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => EventFormSheet( + event: null, + initialDate: calAsync.value!.selectedDay, + notifier: notifier, + ), + ), + child: const Icon(Icons.add), + ) + : null, + ); + } +} + +// ── Agenda list ─────────────────────────────────────────────────────────────── + +class _AgendaList extends StatelessWidget { + final List events; + final CalendarNotifier notifier; + + const _AgendaList({required this.events, required this.notifier}); + + @override + Widget build(BuildContext context) { + if (events.isEmpty) { + return const Center(child: Text('No events')); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: events.length, + itemBuilder: (_, i) => + _EventTile(event: events[i], notifier: notifier), + ); + } +} + +// ── Event tile ──────────────────────────────────────────────────────────────── + +class _EventTile extends StatelessWidget { + final CalendarEvent event; + final CalendarNotifier notifier; + + const _EventTile({required this.event, required this.notifier}); + + Color _dotColor(BuildContext context) { + if (event.color.isEmpty) return Theme.of(context).colorScheme.primary; + try { + return Color(int.parse(event.color.replaceFirst('#', '0xFF'))); + } catch (_) { + return Theme.of(context).colorScheme.primary; + } + } + + String _timeLabel() { + if (event.allDay) return 'All day'; + final h = event.startDt.hour.toString().padLeft(2, '0'); + final m = event.startDt.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + radius: 6, + backgroundColor: _dotColor(context), + ), + title: Text(event.title), + subtitle: Text(_timeLabel()), + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => EventFormSheet( + event: event, + initialDate: null, + notifier: notifier, + ), + ), + ); + } +} From 4919f7a185000da98468d95c52dde169cc3392ac Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 10:32:53 -0400 Subject: [PATCH 21/28] feat: wire Calendar route to CalendarScreen --- lib/app.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index c1d6b0d..68f8538 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -30,6 +30,7 @@ import 'screens/news/news_screen.dart'; import 'screens/setup/setup_screen.dart'; import 'screens/splash/splash_screen.dart'; import 'screens/tasks/task_edit_screen.dart'; +import 'screens/calendar/calendar_screen.dart'; import 'providers/voice_provider.dart'; import 'widgets/voice_mic_button.dart'; @@ -167,10 +168,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: Routes.calendar, - builder: (_, _) => Scaffold( - appBar: AppBar(title: const Text('Calendar')), - body: const Center(child: Text('Calendar — coming soon')), - ), + builder: (_, _) => const CalendarScreen(), ), ], ); From e2a358a158806abf8bccf27ea5596bd1c8206e35 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 11:46:42 -0400 Subject: [PATCH 22/28] feat: show tool status notifications in chat during generation Parse SSE `status` events alongside `chunk` events and display the status text next to the spinner while the assistant is generating. Previously the Android app discarded all non-chunk SSE events. Co-Authored-By: Claude Sonnet 4.6 --- lib/data/api/chat_api.dart | 29 ++++++-- lib/data/repositories/chat_repository.dart | 3 +- lib/providers/briefing_provider.dart | 87 +++++++++++++++++++++- lib/providers/chat_provider.dart | 35 +++++++-- lib/screens/chat/chat_screen.dart | 11 ++- lib/widgets/chat_message_bubble.dart | 39 ++++++++-- 6 files changed, 180 insertions(+), 24 deletions(-) diff --git a/lib/data/api/chat_api.dart b/lib/data/api/chat_api.dart index 4692812..44f90c0 100644 --- a/lib/data/api/chat_api.dart +++ b/lib/data/api/chat_api.dart @@ -6,6 +6,18 @@ import '../models/conversation.dart'; import '../models/message.dart'; import 'api_client.dart'; +sealed class ChatStreamEvent {} + +class ChatTextChunk extends ChatStreamEvent { + final String text; + ChatTextChunk(this.text); +} + +class ChatStatusUpdate extends ChatStreamEvent { + final String status; // empty string = clear status + ChatStatusUpdate(this.status); +} + class ChatApi { final Dio _dio; const ChatApi(this._dio); @@ -71,8 +83,8 @@ class ChatApi { } } - // Step 2: GET the SSE stream and yield text chunks. - Stream streamGeneration(int conversationId) async* { + // Step 2: GET the SSE stream and yield typed events (text chunks + status updates). + Stream streamGeneration(int conversationId) async* { try { final response = await _dio.get( '/api/chat/conversations/$conversationId/generation/stream', @@ -108,14 +120,21 @@ class ChatApi { if (data == '[DONE]') return; if (currentEvent == 'done' || currentEvent == 'error') return; - // Parse as JSON if possible, otherwise yield raw text. if (currentEvent == 'chunk' || currentEvent.isEmpty) { try { final obj = json.decode(data) as Map; final text = obj['text'] as String? ?? ''; - if (text.isNotEmpty) yield text; + if (text.isNotEmpty) yield ChatTextChunk(text); } catch (_) { - if (data.isNotEmpty) yield data; + if (data.isNotEmpty) yield ChatTextChunk(data); + } + } else if (currentEvent == 'status') { + try { + final obj = json.decode(data) as Map; + final status = obj['status'] as String? ?? ''; + yield ChatStatusUpdate(status); + } catch (_) { + // Ignore malformed status events } } } else if (line.isEmpty) { diff --git a/lib/data/repositories/chat_repository.dart b/lib/data/repositories/chat_repository.dart index 8d548c0..26a6d18 100644 --- a/lib/data/repositories/chat_repository.dart +++ b/lib/data/repositories/chat_repository.dart @@ -1,4 +1,5 @@ import '../api/chat_api.dart'; +export '../api/chat_api.dart' show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate; import '../models/conversation.dart'; import '../models/message.dart'; @@ -14,6 +15,6 @@ class ChatRepository { _api.getMessages(conversationId); Future sendMessage(int conversationId, String content) => _api.sendMessage(conversationId, content); - Stream streamGeneration(int conversationId) => + Stream streamGeneration(int conversationId) => _api.streamGeneration(conversationId); } diff --git a/lib/providers/briefing_provider.dart b/lib/providers/briefing_provider.dart index 42d76be..c866509 100644 --- a/lib/providers/briefing_provider.dart +++ b/lib/providers/briefing_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../data/api/chat_api.dart'; import '../data/models/briefing_conversation.dart'; import '../data/models/message.dart'; import 'api_client_provider.dart'; @@ -48,6 +49,86 @@ class BriefingNotifier extends AsyncNotifier { await future; } + /// Inject a news article as context and trigger generation. + /// + /// Mirrors sendReply() but calls the /discuss endpoint instead of + /// /messages so the backend injects article content before generating. + Future discussArticle(int convId, int itemId) async { + final conv = state.value; + if (conv == null) return; + final chatApi = ref.read(chatApiProvider); + final briefingApi = ref.read(briefingApiProvider); + + final previous = conv.messages; + final placeholder = Message( + conversationId: convId, + role: MessageRole.assistant, + content: '', + status: 'generating', + ); + state = AsyncData(conv.copyWith(messages: [...previous, placeholder])); + ref.read(isBriefingStreamingProvider.notifier).state = true; + + try { + await briefingApi.discussArticle(convId, itemId); + } catch (e) { + state = AsyncData(conv.copyWith(messages: previous)); + ref.read(isBriefingStreamingProvider.notifier).state = false; + rethrow; + } + + // SSE stream (best-effort) + bool streamedContent = false; + try { + await for (final event in chatApi.streamGeneration(convId)) { + if (event is! ChatTextChunk) continue; + streamedContent = true; + final current = state.value; + if (current == null) break; + final msgs = current.messages; + if (msgs.isEmpty) continue; + final updated = + msgs.last.copyWith(content: msgs.last.content + event.text); + state = AsyncData(current.copyWith( + messages: [...msgs.sublist(0, msgs.length - 1), updated])); + } + } catch (_) { + // Fall through to polling. + } + + // Poll until complete (max 20 attempts, 2s apart) + try { + for (var attempt = 0; attempt < 20; attempt++) { + if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); + final fresh = await briefingApi.getMessages(convId); + final done = fresh.any( + (m) => m.role == MessageRole.assistant && m.status != 'generating', + ); + final hasContent = fresh.any( + (m) => m.role == MessageRole.assistant && m.content.isNotEmpty, + ); + final current = state.value; + if (current != null && (!streamedContent || done || hasContent)) { + state = AsyncData(current.copyWith(messages: fresh)); + } + if (done) break; + } + } catch (_) { + final current = state.value; + if (current != null) { + final msgs = current.messages; + if (msgs.isNotEmpty && msgs.last.status == 'generating') { + state = AsyncData(current.copyWith(messages: [ + ...msgs.sublist(0, msgs.length - 1), + msgs.last.copyWith(status: 'complete'), + ])); + } + } + } finally { + ref.read(isBriefingStreamingProvider.notifier).state = false; + } + } + /// Send a reply to today's briefing conversation. /// /// Mirrors MessagesNotifier.sendMessage(): @@ -87,13 +168,15 @@ class BriefingNotifier extends AsyncNotifier { // SSE stream (best-effort) bool streamedContent = false; try { - await for (final chunk in chatApi.streamGeneration(convId)) { + await for (final event in chatApi.streamGeneration(convId)) { + if (event is! ChatTextChunk) continue; streamedContent = true; final current = state.value; if (current == null) break; final msgs = current.messages; if (msgs.isEmpty) continue; - final updated = msgs.last.copyWith(content: msgs.last.content + chunk); + final updated = + msgs.last.copyWith(content: msgs.last.content + event.text); state = AsyncData(current.copyWith( messages: [...msgs.sublist(0, msgs.length - 1), updated])); } diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart index b52ba69..0a6e61d 100644 --- a/lib/providers/chat_provider.dart +++ b/lib/providers/chat_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/conversation.dart'; import '../data/models/message.dart'; +import '../data/repositories/chat_repository.dart'; import 'api_client_provider.dart'; // Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops. @@ -18,6 +19,20 @@ class _IsStreamingNotifier extends Notifier { bool build() => false; } +// Tracks the current tool status text during generation (empty = no status). +final streamingStatusProvider = + NotifierProvider.family<_StreamingStatusNotifier, String, int>( + (convId) => _StreamingStatusNotifier(convId), +); + +class _StreamingStatusNotifier extends Notifier { + // ignore: avoid_unused_constructor_parameters + _StreamingStatusNotifier(int convId); + + @override + String build() => ''; +} + final conversationsProvider = AsyncNotifierProvider>( ConversationsNotifier.new); @@ -103,12 +118,19 @@ class MessagesNotifier extends AsyncNotifier> { // ── Step 2: Stream the response (best effort — silent on failure). ── bool streamedContent = false; try { - await for (final chunk in repo.streamGeneration(convId)) { - streamedContent = true; - final msgs = state.requireValue; - if (msgs.isEmpty) continue; - final updated = msgs.last.copyWith(content: msgs.last.content + chunk); - state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]); + await for (final event in repo.streamGeneration(convId)) { + if (event is ChatTextChunk) { + streamedContent = true; + ref.read(streamingStatusProvider(convId).notifier).state = ''; + final msgs = state.requireValue; + if (msgs.isEmpty) continue; + final updated = + msgs.last.copyWith(content: msgs.last.content + event.text); + state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]); + } else if (event is ChatStatusUpdate) { + ref.read(streamingStatusProvider(convId).notifier).state = + event.status; + } } } catch (_) { // SSE failed — fall through to the polling reload below. @@ -157,6 +179,7 @@ class MessagesNotifier extends AsyncNotifier> { } } finally { ref.read(isStreamingProvider(convId).notifier).state = false; + ref.read(streamingStatusProvider(convId).notifier).state = ''; } } } diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index e526b31..0dbb479 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -89,6 +89,8 @@ class _ChatScreenState extends ConsumerState { Widget build(BuildContext context) { final messagesAsync = ref.watch(messagesProvider(widget.conversationId)); final isStreaming = ref.watch(isStreamingProvider(widget.conversationId)); + final streamingStatus = + ref.watch(streamingStatusProvider(widget.conversationId)); final voiceState = ref.watch(voiceProvider); // Scroll when messages change. @@ -139,8 +141,13 @@ class _ChatScreenState extends ConsumerState { padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 12), itemCount: messages.length, - itemBuilder: (context, i) => - ChatMessageBubble(message: messages[i]), + itemBuilder: (context, i) => ChatMessageBubble( + message: messages[i], + streamingStatus: (i == messages.length - 1 && + messages[i].status == 'generating') + ? streamingStatus + : '', + ), ); }, ), diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 19ed6ea..801afc9 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -7,7 +7,12 @@ import '../data/models/message.dart'; class ChatMessageBubble extends StatelessWidget { final Message message; - const ChatMessageBubble({super.key, required this.message}); + final String streamingStatus; + const ChatMessageBubble({ + super.key, + required this.message, + this.streamingStatus = '', + }); @override Widget build(BuildContext context) { @@ -53,13 +58,31 @@ class ChatMessageBubble extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: isGenerating && message.content.isEmpty - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: scheme.onSurfaceVariant, - ), + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: scheme.onSurfaceVariant, + ), + ), + if (streamingStatus.isNotEmpty) ...[ + const SizedBox(width: 8), + Flexible( + child: Text( + streamingStatus, + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ], ) : MarkdownBody( data: message.content.isEmpty ? '…' : message.content, From d53092028417a2217b495b64961e1c36b3596ecd Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 12:36:00 -0400 Subject: [PATCH 23/28] fix: load tasks via TasksApi in Knowledge view, remove tab counts Tasks are not part of the knowledge API (backend rejects type=task). When the Tasks tab is selected, fetch from /api/tasks and convert to KnowledgeItem. Also removes per-type counts from tab labels to prevent tabs from awkwardly resizing when counts load. Co-Authored-By: Claude Sonnet 4.6 --- lib/data/models/knowledge_item.dart | 18 ++++++++++++++++++ lib/providers/knowledge_provider.dart | 19 +++++++++++++++++++ lib/screens/knowledge/knowledge_screen.dart | 12 ++---------- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/lib/data/models/knowledge_item.dart b/lib/data/models/knowledge_item.dart index d8ff992..b5e2c2a 100644 --- a/lib/data/models/knowledge_item.dart +++ b/lib/data/models/knowledge_item.dart @@ -1,3 +1,5 @@ +import 'task.dart'; + class KnowledgeItem { final int id; final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task' @@ -30,6 +32,22 @@ class KnowledgeItem { required this.updatedAt, }); + factory KnowledgeItem.fromTask(Task task) => KnowledgeItem( + id: task.id, + noteType: 'task', + title: task.title, + body: task.description ?? '', + tags: const [], + projectId: task.projectId, + milestoneId: task.milestoneId, + parentId: task.parentId, + status: task.status.value, + priority: task.priority.value, + dueDate: task.dueDate?.toIso8601String(), + createdAt: task.createdAt, + updatedAt: task.updatedAt, + ); + factory KnowledgeItem.fromJson(Map json) => KnowledgeItem( id: json['id'] as int, noteType: json['note_type'] as String? ?? 'note', diff --git a/lib/providers/knowledge_provider.dart b/lib/providers/knowledge_provider.dart index 822f333..b63ef31 100644 --- a/lib/providers/knowledge_provider.dart +++ b/lib/providers/knowledge_provider.dart @@ -4,6 +4,8 @@ import '../data/models/knowledge_item.dart'; import '../data/repositories/knowledge_repository.dart'; import 'api_client_provider.dart'; +export '../data/models/knowledge_item.dart'; + /// Immutable state for the Knowledge screen's two-tier paginated feed. class KnowledgeState { final List ids; @@ -150,6 +152,23 @@ class KnowledgeNotifier extends Notifier { Future _fetchFromScratch() async { state = state.copyWith(isLoadingIds: true, error: null); try { + if (state.noteType == 'task') { + // Tasks live under /api/tasks — fetch all and convert directly. + final tasks = await ref.read(tasksApiProvider).getAll(); + final items = { + for (final t in tasks) t.id: KnowledgeItem.fromTask(t), + }; + state = state.copyWith( + ids: tasks.map((t) => t.id).toList(), + items: items, + totalIds: tasks.length, + isLoadingIds: false, + hasMore: false, + ); + await _loadCounts(); + return; + } + final (ids, total) = await _repo.fetchIds( noteType: state.noteType, tags: state.activeTags, diff --git a/lib/screens/knowledge/knowledge_screen.dart b/lib/screens/knowledge/knowledge_screen.dart index e46d778..0fbe1d8 100644 --- a/lib/screens/knowledge/knowledge_screen.dart +++ b/lib/screens/knowledge/knowledge_screen.dart @@ -74,15 +74,7 @@ class _KnowledgeScreenState extends ConsumerState }); } - String _tabLabel(int index, Map counts) { - final tab = _kTabs[index]; - if (tab.type == null) { - final total = counts.values.fold(0, (a, b) => a + b); - return total > 0 ? 'All ($total)' : 'All'; - } - final count = counts[tab.type]; - return count != null && count > 0 ? '${tab.label} ($count)' : tab.label; - } + String _tabLabel(int index) => _kTabs[index].label; @override Widget build(BuildContext context) { @@ -119,7 +111,7 @@ class _KnowledgeScreenState extends ConsumerState tabAlignment: TabAlignment.start, tabs: List.generate( _kTabs.length, - (i) => Tab(text: _tabLabel(i, state.counts)), + (i) => Tab(text: _tabLabel(i)), ), ), ), From 5014eca9ac2f5de84ae074c30101167fed64e07f Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 12:45:33 -0400 Subject: [PATCH 24/28] feat: auto-refresh data on app resume and tab switch - App resume: invalidates all main providers (conversations, calendar, news, knowledge, briefing) when the app returns to foreground. Throttled to once per 5 minutes to avoid hammering the server. - Tab switch: refreshes the incoming tab's provider when navigating between Briefing / Knowledge / Conversations shell tabs. Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/app.dart b/lib/app.dart index 68f8538..ddba82b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,6 +11,11 @@ import 'providers/api_client_provider.dart'; import 'providers/auth_provider.dart'; import 'providers/capture_queue_provider.dart'; import 'providers/capture_work_queue_provider.dart'; +import 'providers/briefing_provider.dart'; +import 'providers/calendar_provider.dart'; +import 'providers/chat_provider.dart'; +import 'providers/knowledge_provider.dart'; +import 'providers/news_provider.dart'; import 'providers/notes_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/update_provider.dart'; @@ -182,16 +187,22 @@ class _Shell extends ConsumerStatefulWidget { ConsumerState<_Shell> createState() => _ShellState(); } -class _ShellState extends ConsumerState<_Shell> { +class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { static const _tabs = [ Routes.briefing, Routes.knowledge, Routes.conversations, ]; + // Minimum gap between app-resume refreshes to avoid hammering the server. + static const _resumeCooldown = Duration(minutes: 5); + DateTime? _lastResumeRefresh; + int? _prevTabIndex; + @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { // Silent update check — only if we haven't already checked this session. final repoUrl = ref.read(forgejoRepoUrlProvider); @@ -206,6 +217,49 @@ class _ShellState extends ConsumerState<_Shell> { }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + final now = DateTime.now(); + if (_lastResumeRefresh != null && + now.difference(_lastResumeRefresh!) < _resumeCooldown) { + return; + } + _lastResumeRefresh = now; + _refreshAll(); + } + + /// Refresh every major data provider. Safe to call speculatively — + /// providers that aren't currently watched are already disposed. + void _refreshAll() { + ref.invalidate(conversationsProvider); + ref.invalidate(calendarProvider); + ref.invalidate(newsProvider); + // Notifier (not AsyncNotifier) — needs explicit refresh call. + ref.read(knowledgeProvider.notifier).refresh(); + // briefingProvider is an AsyncNotifier family; invalidating the family + // is safe even if no conversation is open. + ref.invalidate(briefingProvider); + } + + /// Refresh only the provider backing the given shell tab index. + void _refreshTab(int index) { + switch (index) { + case 0: + ref.invalidate(briefingProvider); + case 1: + ref.read(knowledgeProvider.notifier).refresh(); + case 2: + ref.invalidate(conversationsProvider); + } + } + Future _syncTimezone() async { try { final tzInfo = await FlutterTimezone.getLocalTimezone(); @@ -340,6 +394,13 @@ class _ShellState extends ConsumerState<_Shell> { }); final location = GoRouterState.of(context).matchedLocation; final index = _tabIndex(location); + + // Refresh the incoming tab's data when switching between shell tabs. + if (_prevTabIndex != null && _prevTabIndex != index && index < 3) { + WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index)); + } + _prevTabIndex = index; + final child = widget.child; final isWide = MediaQuery.of(context).size.width >= 600; From 03dc9108a35c72483331b1ad91afc4c8810135ad Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 17:34:20 -0400 Subject: [PATCH 25/28] docs: update README to reflect current app features and architecture Replaces outdated Library/stub descriptions with accurate coverage of Knowledge, Calendar, News, Voice I/O, chat tool-status notifications, auto-refresh, and the current file tree. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 66 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ba254cf..9c65a23 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,16 @@ Native Android client for FabledAssistant, a self-hosted AI second-brain and pro - **Daily Briefing** — the primary screen; opens on launch. Shows your AI-compiled morning digest (tasks, calendar, weather, RSS) with a full conversation you can reply to inline. Refresh manually or browse past briefings from the overflow menu. - **Quick Capture** — always-visible input bar above all tabs. Type a note or task and submit; multiple captures queue sequentially so the input is never blocked. Falls back to offline persistence when the server is unreachable. -- **Library** — unified browsable list of notes, tasks, and projects with filter pills (All · Notes · Tasks · Projects). Tasks have a secondary status sub-filter and a tappable status icon to cycle todo → in progress → done without opening the editor. Inline search filters results live. -- **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one. +- **Knowledge** — unified browsable feed of notes, people, places, lists, and tasks across six filter tabs. Tag filters, inline search (debounced), and two-tier pagination (ID list → batch hydration). Tasks load from `/api/tasks` directly and are fully integrated with the knowledge feed. +- **Chat** — streaming AI conversations with real-time SSE display, including live tool-use status notifications (e.g. "Calling create_note…"). Tap + to start a new conversation or open an existing one. +- **Calendar** — month strip + daily agenda view backed by the server's internal event store. Full event CRUD with a modal form: title, all-day toggle, start/end date+time pickers, repeat (None/Daily/Weekly/Monthly/Yearly), description, location, and colour chips. Custom RRULE strings are preserved read-only. +- **News** — RSS article feed with per-feed filtering and reactions. Tap "Discuss" to open any article in a new chat conversation. +- **Projects** — browse and edit projects; tap a project to see its milestone-grouped task list. +- **Voice I/O** — tap the microphone in the capture bar or chat to dictate. Server-side STT transcribes audio; TTS reads assistant replies aloud in voice mode. - **Note & task editing** — full Markdown editor for notes, task editor with due date, priority, project, and milestone assignment. - **OAuth / SSO** — authenticates via your server's OIDC provider; local username/password login also supported if enabled server-side. - **Session persistence** — stays logged in across restarts via a persistent cookie jar. +- **Auto-refresh** — data refreshes automatically when the app returns to the foreground (throttled to once per 5 minutes) and when switching between shell tabs. - **Auto-update** — checks your Forgejo releases on launch and prompts to download and install new APKs in-app. ## Requirements @@ -39,33 +44,50 @@ On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.exa ``` lib/ -├── main.dart # Entry point; resolves async deps before runApp -├── app.dart # GoRouter + auth redirect guards + 3-tab shell +├── main.dart # Entry point; resolves async deps before runApp +├── app.dart # GoRouter + auth guards + 3-tab shell + auto-refresh ├── core/ -│ ├── constants.dart # Route name constants -│ ├── exceptions.dart # AppException hierarchy -│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography +│ ├── constants.dart # Route name constants +│ ├── exceptions.dart # AppException hierarchy +│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography ├── data/ -│ ├── api/ # Dio HTTP layer (one class per resource) -│ ├── models/ # Plain Dart models with fromJson/toJson -│ └── repositories/ # Thin wrappers over API classes -├── providers/ # Riverpod providers (state + dependency wiring) -│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling +│ ├── api/ # Dio HTTP layer (one class per resource) +│ │ ├── chat_api.dart # SSE streaming; typed ChatStreamEvent (text/status) +│ │ ├── events_api.dart # Calendar event CRUD +│ │ ├── knowledge_api.dart # Two-tier paginated knowledge feed +│ │ ├── news_api.dart # RSS feed + reactions +│ │ └── voice_api.dart # STT + TTS endpoints +│ ├── models/ # Plain Dart models with fromJson/toJson +│ │ ├── calendar_event.dart # CalendarEvent + dateOnly() helper +│ │ ├── knowledge_item.dart # KnowledgeItem (notes/tasks unified) +│ │ └── message.dart # Chat message with streaming status +│ └── repositories/ # Thin wrappers over API classes +├── providers/ # Riverpod providers (state + dependency wiring) +│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling +│ ├── calendar_provider.dart # CalendarNotifier: month navigation + event CRUD +│ ├── chat_provider.dart # Conversations + streaming messages + status +│ ├── knowledge_provider.dart # Two-tier pagination; delegates tasks to TasksApi +│ ├── news_provider.dart # NewsNotifier + FeedsNotifier │ └── capture_work_queue_provider.dart # Sequential in-memory capture queue ├── screens/ -│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen -│ ├── library/ # LibraryScreen (unified notes/tasks/projects) -│ ├── chat/ # ConversationsTabScreen + ChatScreen -│ ├── notes/ # NoteDetailScreen + NoteEditScreen -│ ├── tasks/ # TaskEditScreen +│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen +│ ├── calendar/ # CalendarScreen (TableCalendar) + EventFormSheet +│ ├── chat/ # ConversationsTabScreen + ChatScreen +│ ├── knowledge/ # KnowledgeScreen (6-tab feed + search + tag filters) +│ ├── library/ # ProjectTasksScreen (milestone-grouped task list) +│ ├── news/ # NewsScreen (feed filter + reactions + discuss) +│ ├── notes/ # NoteDetailScreen + NoteEditScreen +│ ├── projects/ # ProjectsScreen + ProjectEditScreen +│ ├── tasks/ # TaskEditScreen │ └── settings/ auth/ setup/ splash/ └── widgets/ - ├── chat_message_bubble.dart # Shared bubble (used by Chat + Briefing) - ├── briefing_digest_card.dart # Expandable first-message card - └── library_item_card.dart # Note / task / project row widgets + ├── chat_message_bubble.dart # Shared bubble (Chat + Briefing); shows tool status + ├── knowledge_item_card.dart # Card for notes, people, places, lists, tasks + ├── news_card.dart # RSS article card with reactions + └── voice_mic_button.dart # Animated mic button (capture bar + chat) ``` -**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus` +**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`, `table_calendar`, `record`, `just_audio` ## Building a Release APK @@ -91,7 +113,7 @@ The build job has `needs: [analyze]` — a failed analyze or test blocks the APK To cut a release: ```bash -git tag v26.03.11 && git push origin v26.03.11 +git tag v26.04.06 && git push origin v26.04.06 ``` Requires a `RELEASE_TOKEN` secret (Forgejo PAT with `write:repository` scope) set in repo Settings → Secrets → Actions. From d441dcf954f0b2af194f60696e5f551503c4caf0 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 17:39:21 -0400 Subject: [PATCH 26/28] =?UTF-8?q?fix(voice):=20fix=20silent=20STT=20failur?= =?UTF-8?q?e=20=E2=80=94=20use=20AAC/M4A,=20store=20onError,=20guard=20NaN?= =?UTF-8?q?=20amplitude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs causing silent STT failure on Android: 1. AudioEncoder.opus produces an OGG container on Android but the file was named .webm — faster-whisper rejected it due to format mismatch. Changed to AudioEncoder.aacLc + .m4a (reliable on all Android versions). Updated voice_api.dart to send audio/mp4 MIME type accordingly. 2. onError callback was never stored, so errors in _startListening() and _handleSilence() were silently swallowed. Now stored as _onError and called before exitVoiceMode() on any failure. 3. Amplitude stream can emit NaN or ±Infinity on some Android devices during recorder initialisation. NaN < -40.0 is false, so silence was never detected. Now treated explicitly as silence. Co-Authored-By: Claude Sonnet 4.6 --- lib/data/api/voice_api.dart | 4 ++-- lib/providers/voice_provider.dart | 39 +++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/lib/data/api/voice_api.dart b/lib/data/api/voice_api.dart index 42e3dad..6fed7b4 100644 --- a/lib/data/api/voice_api.dart +++ b/lib/data/api/voice_api.dart @@ -46,8 +46,8 @@ class VoiceApi { final formData = FormData.fromMap({ 'audio': MultipartFile.fromBytes( audioBytes, - filename: 'audio.webm', - contentType: DioMediaType('audio', 'webm'), + filename: 'audio.m4a', + contentType: DioMediaType('audio', 'mp4'), ), }); final response = await _dio.post( diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 5f1002a..9d7cf80 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -100,6 +100,7 @@ class VoiceNotifier extends Notifier { // Voice mode callbacks Future Function(String transcript)? _onTranscript; + void Function(String message)? _onError; bool _enableTts = false; // Streaming TTS state @@ -165,6 +166,7 @@ class VoiceNotifier extends Notifier { } _onTranscript = onTranscript; + _onError = onError; _enableTts = enableTts; _tempDir = await getTemporaryDirectory(); @@ -184,6 +186,7 @@ class VoiceNotifier extends Notifier { _lastSeenLength = 0; _streamComplete = false; _onTranscript = null; + _onError = null; state = const VoiceState(); } @@ -217,18 +220,25 @@ class VoiceNotifier extends Notifier { state = state.copyWith(mode: VoiceMode.recording); final dir = _tempDir ?? await getTemporaryDirectory(); + // AAC/M4A is reliably supported on Android (unlike WebM/Opus which can + // produce OGG bytes in a .webm file, confusing server-side decoders). final path = - '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.webm'; + '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a'; - await _recorder!.start( - const RecordConfig(encoder: AudioEncoder.opus, sampleRate: 16000), - path: path, - ); + try { + await _recorder!.start( + const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), + path: path, + ); - _amplitudeSubscription?.cancel(); - _amplitudeSubscription = _recorder! - .onAmplitudeChanged(const Duration(milliseconds: 200)) - .listen(_onAmplitude); + _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder! + .onAmplitudeChanged(const Duration(milliseconds: 200)) + .listen(_onAmplitude); + } catch (e) { + _onError?.call('Microphone error: could not start recording'); + exitVoiceMode(); + } } void _onAmplitude(Amplitude event) { @@ -238,7 +248,12 @@ class VoiceNotifier extends Notifier { DateTime.now().millisecondsSinceEpoch - _recordingStartMs; if (elapsed < _minRecordingMs) return; - if (event.current < _silenceThresholdDb) { + final db = event.current; + // Guard against NaN / ±Infinity which can arrive on some Android devices + // when the recorder is initialising. Treat invalid readings as silence. + final isSilent = db.isNaN || db.isInfinite || db < _silenceThresholdDb; + + if (isSilent) { _silenceMs += 200; if (_silenceMs >= _silenceDurationMs) { _amplitudeSubscription?.cancel(); @@ -289,8 +304,8 @@ class VoiceNotifier extends Notifier { if (!_enableTts && state.voiceModeActive) { await _startListening(); } - } catch (_) { - // Network/API error — exit voice mode + } catch (e) { + _onError?.call('Voice error: transcription failed'); exitVoiceMode(); } } From cb3a09756f4287e5a53a31f42f7a24d1aa0a033d Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 17:46:23 -0400 Subject: [PATCH 27/28] fix: create conversations with empty title so server auto-names them The app was creating conversations with title 'New conversation'. The server only generates a title when conv_title is falsy (empty). With a non-empty title, should_gen_title is False for the first message (msg_count % 10 != 0), so auto-naming never fired. Now creates with empty title (matching web app behaviour). The list still displays 'New conversation' as a UI placeholder until the server-generated title arrives. Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/chat/conversations_tab_screen.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/screens/chat/conversations_tab_screen.dart b/lib/screens/chat/conversations_tab_screen.dart index 68f6c7c..a82cb07 100644 --- a/lib/screens/chat/conversations_tab_screen.dart +++ b/lib/screens/chat/conversations_tab_screen.dart @@ -23,7 +23,7 @@ class ConversationsTabScreen extends ConsumerWidget { onPressed: () async { final conv = await ref .read(conversationsProvider.notifier) - .create('New conversation'); + .create(''); if (context.mounted) { context.push(Routes.chat.replaceFirst(':id', '${conv.id}')); } @@ -52,7 +52,7 @@ class ConversationsTabScreen extends ConsumerWidget { onPressed: () async { final conv = await ref .read(conversationsProvider.notifier) - .create('New conversation'); + .create(''); if (context.mounted) { context.push( Routes.chat.replaceFirst(':id', '${conv.id}')); @@ -71,8 +71,10 @@ class ConversationsTabScreen extends ConsumerWidget { final c = convs[i]; return ListTile( leading: const Icon(Icons.chat_bubble_outline), - title: Text(c.title, - maxLines: 1, overflow: TextOverflow.ellipsis), + title: Text( + c.title.isEmpty ? 'New conversation' : c.title, + maxLines: 1, + overflow: TextOverflow.ellipsis), subtitle: Text( _relativeTime(c.updatedAt), style: theme.textTheme.labelSmall, From 79dce1a01c0488606898a806c06f3068dab7a46f Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 17:49:16 -0400 Subject: [PATCH 28/28] feat: wire discuss button in briefing RSS cards, cap cards at 3 Adds discussArticle() to BriefingApi and wires it through to the RSS news cards in BriefingScreen so tapping Discuss opens a chat conversation seeded with the article. Also caps RSS cards per message at 3 to avoid overly long briefing threads. Co-Authored-By: Claude Sonnet 4.6 --- lib/data/api/briefing_api.dart | 16 +++++++++++ lib/screens/briefing/briefing_screen.dart | 28 +++++++++++++++++-- linux/flutter/generated_plugin_registrant.cc | 4 +++ linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 ++++ .../flutter/generated_plugin_registrant.cc | 3 ++ windows/flutter/generated_plugins.cmake | 1 + 7 files changed, 57 insertions(+), 2 deletions(-) diff --git a/lib/data/api/briefing_api.dart b/lib/data/api/briefing_api.dart index e28b600..27e7c7e 100644 --- a/lib/data/api/briefing_api.dart +++ b/lib/data/api/briefing_api.dart @@ -70,6 +70,22 @@ class BriefingApi { } } + /// POST /api/briefing/articles/{itemId}/discuss body: {"conv_id": convId} + /// Injects the article as context and triggers LLM generation. + /// Returns the assistant_message_id of the generating placeholder. + Future discussArticle(int convId, int itemId) async { + try { + final response = await _dio.post( + '/api/briefing/articles/$itemId/discuss', + data: {'conv_id': convId}, + ); + final data = response.data as Map; + return data['assistant_message_id'] as int; + } on DioException catch (e) { + throw dioToApp(e); + } + } + /// DELETE /api/briefing/rss-reactions/{rssItemId} Future deleteRssReaction(int rssItemId) async { try { diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 4ff546f..8b5bde0 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -93,6 +93,23 @@ class _BriefingScreenState extends ConsumerState } } + Future _handleDiscuss(int convId, int itemId) async { + try { + await ref.read(briefingProvider.notifier).discussArticle(convId, itemId); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to start discussion.')), + ); + } + } + } + Future _handleReaction(int itemId, String reaction) async { final current = _reactions[itemId]; final next = current == reaction ? null : reaction; @@ -268,8 +285,10 @@ class _BriefingScreenState extends ConsumerState final msg = conv.messages[i]; return _BriefingMessageItem( message: msg, + convId: conv.id, reactions: _reactions, onReaction: _handleReaction, + onDiscuss: _handleDiscuss, ); }, ), @@ -370,13 +389,17 @@ class _BriefingScreenState extends ConsumerState /// and RSS reaction buttons below it (for assistant messages with metadata). class _BriefingMessageItem extends StatelessWidget { final Message message; + final int convId; final Map reactions; final void Function(int itemId, String reaction) onReaction; + final void Function(int convId, int itemId) onDiscuss; const _BriefingMessageItem({ required this.message, + required this.convId, required this.reactions, required this.onReaction, + required this.onDiscuss, }); @override @@ -388,11 +411,11 @@ class _BriefingMessageItem extends StatelessWidget { final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather'); final weatherData = hasWeatherKey ? meta['weather'] as Map? : null; - // RSS news cards + // RSS news cards — cap at 3 final rssItemsRaw = isAssistant && meta != null ? (meta['rss_items'] as List?)?.cast>() ?? [] : >[]; - final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).toList(); + final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -408,6 +431,7 @@ class _BriefingMessageItem extends StatelessWidget { item: item, reaction: reactions[item.id], onReaction: onReaction, + onDiscuss: () => onDiscuss(convId, item.id), )).toList(), ), ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index bcdce0a..f06a07c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 8c4a3aa..641253b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_timezone open_file_linux + record_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index bd01ad7..27878f5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,18 +5,24 @@ import FlutterMacOS import Foundation +import audio_session import flutter_inappwebview_macos import flutter_timezone +import just_audio import open_file_mac import package_info_plus +import record_darwin import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index cb273b1..f1cac88 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9474322..f53b4aa 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_inappwebview_windows flutter_timezone permission_handler_windows + record_windows url_launcher_windows )