Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab3a482705 | |||
| 47c190891e | |||
| 3e888b6458 | |||
| 6c29b685e8 | |||
| 5957551546 | |||
| d2582f9111 | |||
| 36350d35b1 | |||
| 96e6b6466f | |||
| d75d34ce8e | |||
| 1c97f9dea5 | |||
| c177bf0691 | |||
| 4ebc57d2e5 | |||
| 946b70ecc4 | |||
| 6ea268bf58 | |||
| 7e332530fb | |||
| 79dce1a01c | |||
| cb3a09756f | |||
| d441dcf954 | |||
| 03dc9108a3 | |||
| 5014eca9ac | |||
| d530920284 | |||
| e2a358a158 | |||
| 4919f7a185 | |||
| 5b639dbd4c | |||
| 334882520c | |||
| c4dca6d4ed | |||
| 776b394874 | |||
| b56c0fc02d | |||
| 8a0837a843 | |||
| 8e5a95b0f2 | |||
| 3a07221968 | |||
| e7d174cef7 | |||
| f4e39c00eb | |||
| e08a8906e3 | |||
| 01ea6b48db | |||
| b56f3d3a0f | |||
| 77fc82af45 | |||
| a2fc0d6c7d | |||
| 2a2f9e6e85 | |||
| 95d0f529ea | |||
| 36bc36cd9d | |||
| a23af0658a | |||
| 39d9f7e053 |
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<void>(
|
||||
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!`
|
||||
@@ -0,0 +1,823 @@
|
||||
# Android News Screen Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the Android News screen — a paginated, feed-filtered list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
|
||||
|
||||
**Architecture:** `NewsNotifier` (`AsyncNotifier<NewsState>`) holds the accumulated item list, pagination, reaction map, and selected feed. The screen is a `ConsumerStatefulWidget` at `/news`. Discuss calls `POST /api/chat/from-article/{id}` (same endpoint as web) and navigates to the returned conversation. Reactions reuse `briefingApiProvider`. The existing `NewsCard` widget is used unchanged via a `RssItemMeta.fromNewsItem` adapter factory.
|
||||
|
||||
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|---------------|
|
||||
| `lib/data/models/news_item.dart` | Create | `NewsItem` model + `fromJson` |
|
||||
| `lib/data/models/briefing_feed.dart` | Create | `BriefingFeed` model + `fromJson` |
|
||||
| `lib/data/api/news_api.dart` | Create | `getNewsItems(...)` + `getFeeds()` Dio calls |
|
||||
| `lib/data/api/chat_api.dart` | Modify | Add `openArticleInChat(int itemId)` |
|
||||
| `lib/providers/api_client_provider.dart` | Modify | Add `newsApiProvider` |
|
||||
| `lib/widgets/news_card.dart` | Modify | Add `RssItemMeta.fromNewsItem` factory |
|
||||
| `lib/providers/news_provider.dart` | Create | `NewsState`, `NewsNotifier`, `newsProvider`, `feedsProvider` |
|
||||
| `lib/screens/news/news_screen.dart` | Create | News screen UI |
|
||||
| `lib/app.dart` | Modify | Replace News stub route with `NewsScreen()` |
|
||||
| `test/widget_test.dart` | Modify | Add `NewsItem.fromJson` and `BriefingFeed.fromJson` tests |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Data models + tests
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/data/models/news_item.dart`
|
||||
- Create: `lib/data/models/briefing_feed.dart`
|
||||
- Modify: `test/widget_test.dart`
|
||||
|
||||
- [ ] **Step 1: Create `NewsItem` model**
|
||||
|
||||
Create `lib/data/models/news_item.dart`:
|
||||
|
||||
```dart
|
||||
class NewsItem {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String snippet;
|
||||
final String source;
|
||||
final DateTime? publishedAt;
|
||||
final List<String> topics;
|
||||
final String? reaction;
|
||||
|
||||
const NewsItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.snippet,
|
||||
required this.source,
|
||||
this.publishedAt,
|
||||
required this.topics,
|
||||
this.reaction,
|
||||
});
|
||||
|
||||
factory NewsItem.fromJson(Map<String, dynamic> json) => NewsItem(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
snippet: json['snippet'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
publishedAt: json['published_at'] != null
|
||||
? DateTime.tryParse(json['published_at'] as String)
|
||||
: null,
|
||||
topics: (json['topics'] as List<dynamic>?)
|
||||
?.cast<String>()
|
||||
.toList() ??
|
||||
[],
|
||||
reaction: json['reaction'] as String?,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `BriefingFeed` model**
|
||||
|
||||
Create `lib/data/models/briefing_feed.dart`:
|
||||
|
||||
```dart
|
||||
class BriefingFeed {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String? category;
|
||||
|
||||
const BriefingFeed({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
this.category,
|
||||
});
|
||||
|
||||
factory BriefingFeed.fromJson(Map<String, dynamic> json) => BriefingFeed(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
category: json['category'] as String?,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write model tests**
|
||||
|
||||
Add these groups to `test/widget_test.dart` (before the closing `}`):
|
||||
|
||||
```dart
|
||||
group('NewsItem.fromJson', () {
|
||||
test('parses all fields', () {
|
||||
final json = {
|
||||
'id': 42,
|
||||
'title': 'Big news',
|
||||
'url': 'https://example.com/article',
|
||||
'snippet': 'A short summary.',
|
||||
'source': 'Example News',
|
||||
'published_at': '2026-01-15T10:00:00',
|
||||
'topics': ['tech', 'ai'],
|
||||
'reaction': 'up',
|
||||
};
|
||||
final item = NewsItem.fromJson(json);
|
||||
expect(item.id, equals(42));
|
||||
expect(item.title, equals('Big news'));
|
||||
expect(item.url, equals('https://example.com/article'));
|
||||
expect(item.snippet, equals('A short summary.'));
|
||||
expect(item.source, equals('Example News'));
|
||||
expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00')));
|
||||
expect(item.topics, equals(['tech', 'ai']));
|
||||
expect(item.reaction, equals('up'));
|
||||
});
|
||||
|
||||
test('handles null published_at and reaction', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'title': '',
|
||||
'url': '',
|
||||
'snippet': '',
|
||||
'source': '',
|
||||
'published_at': null,
|
||||
'topics': <dynamic>[],
|
||||
'reaction': null,
|
||||
};
|
||||
final item = NewsItem.fromJson(json);
|
||||
expect(item.publishedAt, isNull);
|
||||
expect(item.reaction, isNull);
|
||||
expect(item.topics, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('BriefingFeed.fromJson', () {
|
||||
test('parses all fields', () {
|
||||
final json = {
|
||||
'id': 7,
|
||||
'title': 'Hacker News',
|
||||
'url': 'https://news.ycombinator.com/rss',
|
||||
'category': 'tech',
|
||||
};
|
||||
final feed = BriefingFeed.fromJson(json);
|
||||
expect(feed.id, equals(7));
|
||||
expect(feed.title, equals('Hacker News'));
|
||||
expect(feed.url, equals('https://news.ycombinator.com/rss'));
|
||||
expect(feed.category, equals('tech'));
|
||||
});
|
||||
|
||||
test('handles null category', () {
|
||||
final json = {
|
||||
'id': 8,
|
||||
'title': 'Feed',
|
||||
'url': 'https://example.com/rss',
|
||||
'category': null,
|
||||
};
|
||||
final feed = BriefingFeed.fromJson(json);
|
||||
expect(feed.category, isNull);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Also add these imports at the top of `test/widget_test.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:fabled_app/data/models/news_item.dart';
|
||||
import 'package:fabled_app/data/models/briefing_feed.dart';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter test
|
||||
```
|
||||
|
||||
Expected: All tests passed (17 total — 15 existing + 2 new groups = 4 new tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/data/models/news_item.dart lib/data/models/briefing_feed.dart test/widget_test.dart
|
||||
git commit -m "feat: add NewsItem and BriefingFeed models with tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: API layer
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/data/api/news_api.dart`
|
||||
- Modify: `lib/data/api/chat_api.dart`
|
||||
- Modify: `lib/providers/api_client_provider.dart`
|
||||
|
||||
- [ ] **Step 1: Create `NewsApi`**
|
||||
|
||||
Create `lib/data/api/news_api.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/briefing_feed.dart';
|
||||
import '../models/news_item.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class NewsApi {
|
||||
final Dio _dio;
|
||||
const NewsApi(this._dio);
|
||||
|
||||
/// GET /api/briefing/news
|
||||
/// Returns up to [limit] items starting at [offset], optionally filtered by [feedId].
|
||||
Future<List<NewsItem>> getNewsItems({
|
||||
int days = 90,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
int? feedId,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'days': days,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
if (feedId != null) 'feed_id': feedId,
|
||||
};
|
||||
final response = await _dio.get(
|
||||
'/api/briefing/news',
|
||||
queryParameters: params,
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['items'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/feeds
|
||||
Future<List<BriefingFeed>> getFeeds() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/feeds');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `openArticleInChat` to `ChatApi`**
|
||||
|
||||
In `lib/data/api/chat_api.dart`, add this method at the end of the `ChatApi` class (before the closing `}`):
|
||||
|
||||
```dart
|
||||
/// POST /api/chat/from-article/{itemId}
|
||||
/// Creates or retrieves a chat conversation seeded with the article.
|
||||
/// Returns the conversation_id.
|
||||
Future<int> openArticleInChat(int itemId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['conversation_id'] as int;
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `newsApiProvider` to `api_client_provider.dart`**
|
||||
|
||||
In `lib/providers/api_client_provider.dart`, add the import and provider.
|
||||
|
||||
Add import after the existing API imports (e.g., after `voice_api.dart`):
|
||||
```dart
|
||||
import '../data/api/news_api.dart';
|
||||
```
|
||||
|
||||
Add provider after `voiceRepositoryProvider`:
|
||||
```dart
|
||||
final newsApiProvider = Provider<NewsApi>((ref) {
|
||||
return NewsApi(ref.watch(dioProvider));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart
|
||||
```
|
||||
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart
|
||||
git commit -m "feat: add NewsApi, openArticleInChat, and newsApiProvider"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RssItemMeta adapter
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/widgets/news_card.dart`
|
||||
|
||||
`NewsCard` renders `RssItemMeta` objects. Rather than modifying the widget, we add a factory on `RssItemMeta` that converts a `NewsItem`.
|
||||
|
||||
- [ ] **Step 1: Add `fromNewsItem` factory**
|
||||
|
||||
In `lib/widgets/news_card.dart`, add this import at the top:
|
||||
|
||||
```dart
|
||||
import '../data/models/news_item.dart';
|
||||
```
|
||||
|
||||
Then add this factory inside the `RssItemMeta` class, after the existing `fromJson` factory:
|
||||
|
||||
```dart
|
||||
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
source: item.source,
|
||||
snippet: item.snippet,
|
||||
publishedAt: item.publishedAt,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/widgets/news_card.dart
|
||||
```
|
||||
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/widgets/news_card.dart
|
||||
git commit -m "feat: add RssItemMeta.fromNewsItem adapter factory"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Providers
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/providers/news_provider.dart`
|
||||
|
||||
- [ ] **Step 1: Create `news_provider.dart`**
|
||||
|
||||
Create `lib/providers/news_provider.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/briefing_feed.dart';
|
||||
import '../data/models/news_item.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
// ─── NewsState ────────────────────────────────────────────────────────────────
|
||||
|
||||
class NewsState {
|
||||
final List<NewsItem> items;
|
||||
final int offset;
|
||||
final bool hasMore;
|
||||
final bool loadingMore;
|
||||
final int? selectedFeedId;
|
||||
final Map<int, String?> reactions;
|
||||
|
||||
const NewsState({
|
||||
required this.items,
|
||||
required this.offset,
|
||||
required this.hasMore,
|
||||
required this.loadingMore,
|
||||
required this.selectedFeedId,
|
||||
required this.reactions,
|
||||
});
|
||||
|
||||
NewsState copyWith({
|
||||
List<NewsItem>? items,
|
||||
int? offset,
|
||||
bool? hasMore,
|
||||
bool? loadingMore,
|
||||
Object? selectedFeedId = _sentinel,
|
||||
Map<int, String?>? reactions,
|
||||
}) {
|
||||
return NewsState(
|
||||
items: items ?? this.items,
|
||||
offset: offset ?? this.offset,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
selectedFeedId: selectedFeedId == _sentinel
|
||||
? this.selectedFeedId
|
||||
: selectedFeedId as int?,
|
||||
reactions: reactions ?? this.reactions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _sentinel = Object();
|
||||
|
||||
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
|
||||
|
||||
final newsProvider =
|
||||
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
|
||||
|
||||
class NewsNotifier extends AsyncNotifier<NewsState> {
|
||||
static const _limit = 40;
|
||||
|
||||
@override
|
||||
Future<NewsState> build() async {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
);
|
||||
return NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: null,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.loadingMore || !current.hasMore) return;
|
||||
state = AsyncData(current.copyWith(loadingMore: true));
|
||||
try {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: current.offset,
|
||||
feedId: current.selectedFeedId,
|
||||
);
|
||||
final updatedReactions = Map<int, String?>.from(current.reactions);
|
||||
for (final item in items) {
|
||||
updatedReactions.putIfAbsent(item.id, () => item.reaction);
|
||||
}
|
||||
state = AsyncData(current.copyWith(
|
||||
items: [...current.items, ...items],
|
||||
offset: current.offset + items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
reactions: updatedReactions,
|
||||
));
|
||||
} catch (e) {
|
||||
state = AsyncData(current.copyWith(loadingMore: false));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setFeed(int? feedId) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
feedId: feedId,
|
||||
);
|
||||
state = AsyncData(NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: feedId,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleReaction(int itemId, String reaction) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final prev = current.reactions[itemId];
|
||||
final next = prev == reaction ? null : reaction;
|
||||
state = AsyncData(current.copyWith(
|
||||
reactions: {...current.reactions, itemId: next},
|
||||
));
|
||||
final briefingApi = ref.read(briefingApiProvider);
|
||||
final future = next == null
|
||||
? briefingApi.deleteRssReaction(itemId)
|
||||
: briefingApi.postRssReaction(itemId, next);
|
||||
future.catchError((_) {
|
||||
final s = state.value;
|
||||
if (s != null) {
|
||||
state = AsyncData(s.copyWith(
|
||||
reactions: {...s.reactions, itemId: prev},
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
|
||||
|
||||
final feedsProvider =
|
||||
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
|
||||
|
||||
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
|
||||
@override
|
||||
Future<List<BriefingFeed>> build() async {
|
||||
return ref.read(newsApiProvider).getFeeds();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/providers/news_provider.dart
|
||||
```
|
||||
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/providers/news_provider.dart
|
||||
git commit -m "feat: add NewsNotifier and feedsProvider"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: News screen
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/news/news_screen.dart`
|
||||
|
||||
- [ ] **Step 1: Create the screen**
|
||||
|
||||
Create `lib/screens/news/news_screen.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../data/models/briefing_feed.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/news_provider.dart';
|
||||
import '../../widgets/news_card.dart';
|
||||
|
||||
class NewsScreen extends ConsumerStatefulWidget {
|
||||
const NewsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NewsScreen> createState() => _NewsScreenState();
|
||||
}
|
||||
|
||||
class _NewsScreenState extends ConsumerState<NewsScreen> {
|
||||
final Set<int> _openingChat = {};
|
||||
|
||||
Future<void> _handleDiscuss(int itemId) async {
|
||||
if (_openingChat.contains(itemId)) return;
|
||||
setState(() => _openingChat.add(itemId));
|
||||
try {
|
||||
final conversationId =
|
||||
await ref.read(chatApiProvider).openArticleInChat(itemId);
|
||||
if (mounted) {
|
||||
context.push(Routes.chat.replaceFirst(':id', '$conversationId'));
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to open article in chat.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _openingChat.remove(itemId));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
try {
|
||||
await ref.read(newsProvider.notifier).loadMore();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to load more articles.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final newsAsync = ref.watch(newsProvider);
|
||||
final feedsAsync = ref.watch(feedsProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('News', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
'Last 90 days',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: newsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Could not load news."),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(newsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (news) => Column(
|
||||
children: [
|
||||
_FeedFilter(
|
||||
feeds: feedsAsync.value ?? [],
|
||||
selectedFeedId: news.selectedFeedId,
|
||||
onChanged: (feedId) =>
|
||||
ref.read(newsProvider.notifier).setFeed(feedId),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
itemCount: news.items.length + 1,
|
||||
itemBuilder: (_, i) {
|
||||
if (i == news.items.length) {
|
||||
if (!news.hasMore) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(
|
||||
child: news.loadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: FilledButton.tonal(
|
||||
onPressed: _loadMore,
|
||||
child: const Text('Load more'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final item = news.items[i];
|
||||
return NewsCard(
|
||||
item: RssItemMeta.fromNewsItem(item),
|
||||
reaction: news.reactions[item.id],
|
||||
onReaction: (itemId, reaction) => ref
|
||||
.read(newsProvider.notifier)
|
||||
.toggleReaction(itemId, reaction),
|
||||
onDiscuss: _openingChat.contains(item.id)
|
||||
? null
|
||||
: () => _handleDiscuss(item.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedFilter extends StatelessWidget {
|
||||
final List<BriefingFeed> feeds;
|
||||
final int? selectedFeedId;
|
||||
final void Function(int? feedId) onChanged;
|
||||
|
||||
const _FeedFilter({
|
||||
required this.feeds,
|
||||
required this.selectedFeedId,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Feed:',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DropdownButton<int?>(
|
||||
value: selectedFeedId,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('All feeds'),
|
||||
),
|
||||
...feeds.map(
|
||||
(f) => DropdownMenuItem<int?>(
|
||||
value: f.id,
|
||||
child: Text(f.title),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => onChanged(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/screens/news/news_screen.dart
|
||||
```
|
||||
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/screens/news/news_screen.dart
|
||||
git commit -m "feat: add NewsScreen with feed filter, reactions, and discuss"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire route and final verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/app.dart`
|
||||
|
||||
- [ ] **Step 1: Replace the News stub route with `NewsScreen`**
|
||||
|
||||
In `lib/app.dart`, add the import near the other screen imports:
|
||||
|
||||
```dart
|
||||
import 'screens/news/news_screen.dart';
|
||||
```
|
||||
|
||||
Find and replace the stub route:
|
||||
|
||||
```dart
|
||||
GoRoute(
|
||||
path: Routes.news,
|
||||
builder: (_, _) => Scaffold(
|
||||
appBar: AppBar(title: const Text('News')),
|
||||
body: const Center(child: Text('News — coming soon')),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```dart
|
||||
GoRoute(
|
||||
path: Routes.news,
|
||||
builder: (_, _) => const NewsScreen(),
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Full analyze and tests**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
Expected: `No issues found!` and all tests passed.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/app.dart
|
||||
git commit -m "feat: wire News route to NewsScreen"
|
||||
```
|
||||
@@ -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<CalendarState>`) holds a `Map<DateTime, List<CalendarEvent>>` 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=<iso>&to=<iso>
|
||||
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async { ... }
|
||||
|
||||
// POST /api/events
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async { ... }
|
||||
|
||||
// PATCH /api/events/{id}
|
||||
Future<CalendarEvent> updateEvent(int id, Map<String, dynamic> fields) async { ... }
|
||||
|
||||
// DELETE /api/events/{id}
|
||||
Future<void> 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<EventsApi>((ref) =>
|
||||
EventsApi(ref.watch(dioProvider)));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### `CalendarState`
|
||||
```dart
|
||||
class CalendarState {
|
||||
final Map<DateTime, List<CalendarEvent>> eventsByDay; // keys: midnight local time
|
||||
final DateTime selectedDay;
|
||||
final DateTime focusedMonth;
|
||||
final DateTimeRange loadedRange;
|
||||
}
|
||||
```
|
||||
|
||||
### `CalendarNotifier extends AsyncNotifier<CalendarState>`
|
||||
|
||||
- **`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
|
||||
@@ -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<void>(
|
||||
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
|
||||
@@ -0,0 +1,177 @@
|
||||
# Android News Screen Design
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the Android News screen — a paginated, filterable list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
|
||||
|
||||
**Architecture:** A `NewsNotifier` (`AsyncNotifier`) holds the full accumulated item list, pagination state, and selected feed ID. The screen is a `ConsumerStatefulWidget` accessed via the More bottom sheet at `/news`. Discuss uses `POST /api/chat/from-article/{id}` (same endpoint as the web) so any backend behaviour change is automatically reflected. Reactions reuse the existing `briefingApiProvider` methods. The existing `NewsCard` widget is used without modification.
|
||||
|
||||
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
### New
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `lib/data/models/news_item.dart` | `NewsItem` model + `fromJson` |
|
||||
| `lib/data/models/briefing_feed.dart` | `BriefingFeed` model + `fromJson` |
|
||||
| `lib/data/api/news_api.dart` | `getNewsItems(...)` and `getFeeds()` API calls |
|
||||
| `lib/providers/news_provider.dart` | `NewsNotifier`, `NewsState`, `newsProvider`, `feedsProvider` |
|
||||
| `lib/screens/news/news_screen.dart` | News screen UI |
|
||||
|
||||
### Modified
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `lib/data/api/chat_api.dart` | Add `openArticleInChat(int itemId) → Future<int>` |
|
||||
| `lib/providers/api_client_provider.dart` | Add `newsApiProvider` |
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### `NewsItem`
|
||||
```dart
|
||||
class NewsItem {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String snippet;
|
||||
final String source;
|
||||
final DateTime? publishedAt;
|
||||
final List<String> topics;
|
||||
final String? reaction; // 'up' | 'down' | null
|
||||
}
|
||||
```
|
||||
`fromJson` maps: `id`, `title`, `url`, `snippet`, `source`, `published_at` (nullable ISO string → `DateTime.tryParse`), `topics` (cast `List<dynamic>` → `List<String>`), `reaction` (nullable string).
|
||||
|
||||
### `BriefingFeed`
|
||||
```dart
|
||||
class BriefingFeed {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String? category;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Layer
|
||||
|
||||
### `news_api.dart`
|
||||
|
||||
```dart
|
||||
class NewsApi {
|
||||
final Dio _dio;
|
||||
const NewsApi(this._dio);
|
||||
|
||||
// GET /api/briefing/news
|
||||
Future<NewsItemsResponse> getNewsItems({
|
||||
int days = 90,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
int? feedId,
|
||||
}) async { ... }
|
||||
|
||||
// GET /api/briefing/feeds
|
||||
Future<List<BriefingFeed>> getFeeds() async { ... }
|
||||
}
|
||||
|
||||
class NewsItemsResponse {
|
||||
final List<NewsItem> items;
|
||||
final int offset;
|
||||
final int limit;
|
||||
}
|
||||
```
|
||||
|
||||
### `chat_api.dart` addition
|
||||
```dart
|
||||
// POST /api/chat/from-article/{itemId}
|
||||
// Returns conversation_id
|
||||
Future<int> openArticleInChat(int itemId) async { ... }
|
||||
```
|
||||
|
||||
### `api_client_provider.dart` addition
|
||||
```dart
|
||||
final newsApiProvider = Provider<NewsApi>((ref) =>
|
||||
NewsApi(ref.watch(dioProvider)));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### `NewsState`
|
||||
```dart
|
||||
class NewsState {
|
||||
final List<NewsItem> items;
|
||||
final int offset;
|
||||
final bool hasMore;
|
||||
final bool loadingMore;
|
||||
final int? selectedFeedId;
|
||||
final Map<int, String?> reactions; // item id → 'up'|'down'|null
|
||||
}
|
||||
```
|
||||
|
||||
### `NewsNotifier extends AsyncNotifier<NewsState>`
|
||||
- `build()`: fetches first page (offset=0, no feed filter); initialises `reactions` from `item.reaction` on each item
|
||||
- `loadMore()`: appends next page; no-op if `loadingMore` or `!hasMore`; sets `loadingMore = true` optimistically; on error shows snackbar (error returned to caller, not thrown into AsyncError)
|
||||
- `setFeed(int? feedId)`: resets `items`, `offset`, `hasMore`, `reactions`; sets `selectedFeedId`; triggers `build()`-equivalent reload via `state = AsyncLoading()` + fetch
|
||||
- `toggleReaction(int itemId, String reaction)`: optimistic toggle in `reactions` map; calls `briefingApi.postRssReaction` or `deleteRssReaction`; reverts on error
|
||||
|
||||
### `feedsProvider extends AsyncNotifier<List<BriefingFeed>>`
|
||||
- `build()`: fetches once; cached for the session (no invalidation needed — feeds rarely change)
|
||||
|
||||
---
|
||||
|
||||
## Screen Behaviour
|
||||
|
||||
### `NewsScreen` (`ConsumerStatefulWidget`)
|
||||
|
||||
**AppBar**: title "News", subtitle "Last 90 days"
|
||||
|
||||
**Feed filter row** (below app bar, above list): `DropdownButton` with "All feeds" option + one entry per feed. On change calls `ref.read(newsProvider.notifier).setFeed(id)`.
|
||||
|
||||
**List**: `ListView.builder` of `NewsCard` widgets. Each `NewsCard` receives:
|
||||
- `item`: `RssItemMeta.fromNewsItem(item)` — a thin adapter since `NewsCard` already uses `RssItemMeta`
|
||||
- `reaction`: `newsState.reactions[item.id]`
|
||||
- `onReaction`: calls `notifier.toggleReaction`
|
||||
- `onDiscuss`: calls `_handleDiscuss(item.id)`
|
||||
|
||||
**Load more**: `ListTile` / `FilledButton.tonal` at the bottom — shows "Load more" when `hasMore && !loadingMore`, spinner when `loadingMore`, hidden when `!hasMore`.
|
||||
|
||||
**Initial loading**: `CircularProgressIndicator` centered. Error state shows message + "Retry" button that calls `ref.invalidate(newsProvider)`.
|
||||
|
||||
**Discuss flow** (`_handleDiscuss`):
|
||||
1. Track `_openingChat = {itemId}` in local `setState` (disables that card's button while in flight)
|
||||
2. Call `chatApi.openArticleInChat(itemId)`
|
||||
3. On success: `context.push(Routes.chat.replaceFirst(':id', '$conversationId'))`
|
||||
4. On error: show snackbar "Failed to open article in chat."
|
||||
5. Always: remove from `_openingChat`
|
||||
|
||||
---
|
||||
|
||||
## `RssItemMeta` Adapter
|
||||
|
||||
`NewsCard` currently consumes `RssItemMeta` (from `news_card.dart`). Add a factory on `RssItemMeta`:
|
||||
```dart
|
||||
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
source: item.source,
|
||||
snippet: item.snippet,
|
||||
publishedAt: item.publishedAt,
|
||||
);
|
||||
```
|
||||
This keeps `NewsCard` unchanged and avoids coupling the widget to a second model type.
|
||||
|
||||
---
|
||||
|
||||
## What This Does NOT Include
|
||||
|
||||
- Feed management (add/remove/refresh feeds) — that is a Settings concern
|
||||
- Offline caching
|
||||
- Pull-to-refresh (load-more button is sufficient for the initial pass)
|
||||
+145
-31
@@ -5,16 +5,19 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
|
||||
import 'core/constants.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'core/theme.dart';
|
||||
import 'providers/api_client_provider.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'providers/capture_queue_provider.dart';
|
||||
import 'providers/capture_work_queue_provider.dart';
|
||||
import 'providers/notes_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/settings_provider.dart';
|
||||
import 'providers/update_provider.dart';
|
||||
import 'providers/tasks_provider.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/briefing/briefing_screen.dart';
|
||||
import 'screens/knowledge/knowledge_screen.dart';
|
||||
@@ -26,9 +29,11 @@ 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';
|
||||
import 'screens/calendar/calendar_screen.dart';
|
||||
import 'providers/voice_provider.dart';
|
||||
import 'widgets/voice_mic_button.dart';
|
||||
|
||||
@@ -154,12 +159,20 @@ final routerProvider = Provider<GoRouter>((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: (_, _) => const NewsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.calendar,
|
||||
builder: (_, _) => const CalendarScreen(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -172,17 +185,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,
|
||||
Routes.projects,
|
||||
];
|
||||
|
||||
// Minimum gap between app-resume refreshes to avoid hammering the server.
|
||||
static const _resumeCooldown = Duration(seconds: 30);
|
||||
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);
|
||||
@@ -197,6 +215,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.read(conversationsProvider.notifier).refresh();
|
||||
ref.read(calendarProvider.notifier).refresh();
|
||||
ref.read(newsProvider.notifier).refresh();
|
||||
ref.read(knowledgeProvider.notifier).refresh();
|
||||
// briefingProvider is an AsyncNotifier family; invalidating is safe
|
||||
// even if no conversation is open — it doesn't cause flicker since
|
||||
// the briefing screen isn't a list view.
|
||||
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.read(conversationsProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTimezone() async {
|
||||
try {
|
||||
final tzInfo = await FlutterTimezone.getLocalTimezone();
|
||||
@@ -210,9 +271,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<void>(
|
||||
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<void>(
|
||||
context: context,
|
||||
@@ -289,6 +392,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;
|
||||
|
||||
@@ -303,7 +413,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 +438,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 +470,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 +494,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',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -417,27 +539,19 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
if (!mounted) return;
|
||||
final queue = ref.read(captureQueueProvider);
|
||||
if (queue.isEmpty) return;
|
||||
final api = ref.read(quickCaptureApiProvider);
|
||||
for (final text in List<String>.from(queue)) {
|
||||
if (!mounted) break;
|
||||
try {
|
||||
final result = await api.capture(text);
|
||||
// Dequeue before the mounted check — SharedPreferences doesn't need
|
||||
// the widget alive, and skipping this would leave a ghost item.
|
||||
final conv =
|
||||
await ref.read(conversationsProvider.notifier).create('');
|
||||
final chatRepo = ref.read(chatRepositoryProvider);
|
||||
await chatRepo.sendMessage(conv.id, text);
|
||||
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
if (!mounted) break;
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
}
|
||||
} on NetworkException {
|
||||
break;
|
||||
} catch (_) {
|
||||
// Server error or unexpected failure — drop from queue to prevent
|
||||
// ghost items that can never be cleared.
|
||||
// Server error — drop from queue to prevent ghost items.
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
+17
-17
@@ -3,21 +3,21 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const _darkBackground = Color(0xFF111113);
|
||||
const _darkSurface = Color(0xFF18181C);
|
||||
const _darkSurfaceVar = Color(0xFF1E1E24);
|
||||
const _darkPrimary = Color(0xFF6366F1);
|
||||
const _darkOnSurface = Color(0xFFE8E8F0);
|
||||
const _darkBackground = Color(0xFF0F0F14);
|
||||
const _darkSurface = Color(0xFF16161F);
|
||||
const _darkSurfaceVar = Color(0xFF1A1A24);
|
||||
const _darkPrimary = Color(0xFF7C3AED);
|
||||
const _darkOnSurface = Color(0xFFE4E4F0);
|
||||
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
||||
const _darkOutline = Color(0xFF2E2E3A);
|
||||
const _darkOutline = Color(0xFF2A2A3A);
|
||||
|
||||
const _lightBackground = Color(0xFFF4F4F8);
|
||||
const _lightBackground = Color(0xFFF5F5FB);
|
||||
const _lightSurface = Color(0xFFFFFFFF);
|
||||
const _lightSurfaceVar = Color(0xFFF0F0F5);
|
||||
const _lightPrimary = Color(0xFF4F46E5);
|
||||
const _lightOnSurface = Color(0xFF18181C);
|
||||
const _lightOnSurfaceVar = Color(0xFF6B6B88);
|
||||
const _lightOutline = Color(0xFFD4D4E4);
|
||||
const _lightSurfaceVar = Color(0xFFF0F0F8);
|
||||
const _lightPrimary = Color(0xFF7C3AED);
|
||||
const _lightOnSurface = Color(0xFF1A1A1A);
|
||||
const _lightOnSurfaceVar = Color(0xFF666666);
|
||||
const _lightOutline = Color(0xFFDDDDE8);
|
||||
|
||||
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,7 +41,7 @@ ThemeData fabledDarkTheme() {
|
||||
brightness: Brightness.dark,
|
||||
primary: _darkPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFF3730A3),
|
||||
primaryContainer: const Color(0xFF5B21B6),
|
||||
onPrimaryContainer: _darkOnSurface,
|
||||
secondary: _darkPrimary,
|
||||
onSecondary: Colors.white,
|
||||
@@ -118,7 +118,7 @@ ThemeData fabledLightTheme() {
|
||||
brightness: Brightness.light,
|
||||
primary: _lightPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFFE0E0FF),
|
||||
primaryContainer: const Color(0xFFEDE5FF),
|
||||
onPrimaryContainer: _lightOnSurface,
|
||||
secondary: _lightPrimary,
|
||||
onSecondary: Colors.white,
|
||||
@@ -218,15 +218,15 @@ class GradientButton extends StatelessWidget {
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
|
||||
),
|
||||
color: disabled ? const Color(0xFF6366F1) : null,
|
||||
color: disabled ? const Color(0xFF7C3AED) : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: disabled
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
|
||||
color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
|
||||
@@ -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<int> 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<String, dynamic>;
|
||||
return data['assistant_message_id'] as int;
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/briefing/rss-reactions/{rssItemId}
|
||||
Future<void> deleteRssReaction(int rssItemId) async {
|
||||
try {
|
||||
|
||||
@@ -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<String> streamGeneration(int conversationId) async* {
|
||||
// Step 2: GET the SSE stream and yield typed events (text chunks + status updates).
|
||||
Stream<ChatStreamEvent> 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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
final status = obj['status'] as String? ?? '';
|
||||
yield ChatStatusUpdate(status);
|
||||
} catch (_) {
|
||||
// Ignore malformed status events
|
||||
}
|
||||
}
|
||||
} else if (line.isEmpty) {
|
||||
@@ -127,4 +146,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<int> openArticleInChat(int itemId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['conversation_id'] as int;
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<CalendarEvent>> 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<dynamic>;
|
||||
return list
|
||||
.map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/events
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/events', data: payload);
|
||||
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/events/{id}
|
||||
Future<CalendarEvent> updateEvent(
|
||||
int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final response = await _dio.patch('/api/events/$id', data: fields);
|
||||
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/events/{id}
|
||||
Future<void> deleteEvent(int id) async {
|
||||
try {
|
||||
await _dio.delete('/api/events/$id');
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<NewsItem>> getNewsItems({
|
||||
int days = 90,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
int? feedId,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'days': days,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
if (feedId != null) 'feed_id': feedId,
|
||||
};
|
||||
final response = await _dio.get(
|
||||
'/api/briefing/news',
|
||||
queryParameters: params,
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['items'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/feeds
|
||||
Future<List<BriefingFeed>> getFeeds() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/feeds');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,16 +40,20 @@ class VoiceApi {
|
||||
}
|
||||
|
||||
/// POST WebM/Opus audio bytes and return the transcript string.
|
||||
/// [context] is optional recent conversation text passed as initial_prompt
|
||||
/// to Whisper, reducing mishearings of domain-specific words.
|
||||
/// Returns empty string on empty or error response.
|
||||
Future<String> transcribe(Uint8List audioBytes) async {
|
||||
Future<String> transcribe(Uint8List audioBytes, {String? context}) async {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
final fields = <String, dynamic>{
|
||||
'audio': MultipartFile.fromBytes(
|
||||
audioBytes,
|
||||
filename: 'audio.webm',
|
||||
contentType: DioMediaType('audio', 'webm'),
|
||||
filename: 'audio.m4a',
|
||||
contentType: DioMediaType('audio', 'mp4'),
|
||||
),
|
||||
});
|
||||
if (context != null && context.isNotEmpty) 'context': context,
|
||||
};
|
||||
final formData = FormData.fromMap(fields);
|
||||
final response = await _dio.post(
|
||||
'/api/voice/transcribe',
|
||||
data: formData,
|
||||
|
||||
@@ -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<String, dynamic> json) => BriefingFeed(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
category: json['category'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -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<String, dynamic> json) => CalendarEvent(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
startDt: DateTime.parse(json['start_dt'] as String).toLocal(),
|
||||
endDt: json['end_dt'] != null
|
||||
? DateTime.parse(json['end_dt'] as String).toLocal()
|
||||
: 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);
|
||||
@@ -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<String, dynamic> json) => KnowledgeItem(
|
||||
id: json['id'] as int,
|
||||
noteType: json['note_type'] as String? ?? 'note',
|
||||
|
||||
@@ -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<String> topics;
|
||||
final String? reaction;
|
||||
|
||||
const NewsItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.snippet,
|
||||
required this.source,
|
||||
this.publishedAt,
|
||||
required this.topics,
|
||||
this.reaction,
|
||||
});
|
||||
|
||||
factory NewsItem.fromJson(Map<String, dynamic> json) => NewsItem(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
snippet: json['snippet'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
publishedAt: json['published_at'] != null
|
||||
? DateTime.tryParse(json['published_at'] as String)
|
||||
: null,
|
||||
topics: (json['topics'] as List<dynamic>?)
|
||||
?.cast<String>()
|
||||
.toList() ??
|
||||
[],
|
||||
reaction: json['reaction'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -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<void> sendMessage(int conversationId, String content) =>
|
||||
_api.sendMessage(conversationId, content);
|
||||
Stream<String> streamGeneration(int conversationId) =>
|
||||
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
|
||||
_api.streamGeneration(conversationId);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class VoiceRepository {
|
||||
const VoiceRepository(this._api);
|
||||
|
||||
Future<VoiceStatus> checkStatus() => _api.checkStatus();
|
||||
Future<String> transcribe(Uint8List audioBytes) => _api.transcribe(audioBytes);
|
||||
Future<String> transcribe(Uint8List audioBytes, {String? context}) =>
|
||||
_api.transcribe(audioBytes, context: context);
|
||||
Future<Uint8List> synthesise(String text) => _api.synthesise(text);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ 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';
|
||||
import '../data/api/quick_capture_api.dart';
|
||||
@@ -110,3 +112,11 @@ final voiceApiProvider = Provider<VoiceApi>((ref) {
|
||||
final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
||||
return VoiceRepository(ref.watch(voiceApiProvider));
|
||||
});
|
||||
|
||||
final newsApiProvider = Provider<NewsApi>((ref) {
|
||||
return NewsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||
return EventsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
@@ -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<BriefingConversation> {
|
||||
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<void> 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<BriefingConversation> {
|
||||
// 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]));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
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<DateTime, List<CalendarEvent>> 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<DateTime, List<CalendarEvent>>? 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, CalendarState>(CalendarNotifier.new);
|
||||
|
||||
class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
||||
@override
|
||||
Future<CalendarState> 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),
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-fetch events for the current range without clearing state (no flicker).
|
||||
Future<void> refresh() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final events = await ref.read(eventsApiProvider).getEvents(
|
||||
current.loadedRange.start,
|
||||
current.loadedRange.end,
|
||||
);
|
||||
state = AsyncData(current.copyWith(eventsByDay: _groupByDay(events)));
|
||||
}
|
||||
|
||||
/// 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<void> 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<DateTime, List<CalendarEvent>>.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<DateTime, List<CalendarEvent>>.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<DateTime, List<CalendarEvent>>.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<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
|
||||
byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList();
|
||||
state = AsyncData(current.copyWith(eventsByDay: byDay));
|
||||
}
|
||||
}
|
||||
|
||||
Map<DateTime, List<CalendarEvent>> _groupByDay(List<CalendarEvent> events) {
|
||||
final byDay = <DateTime, List<CalendarEvent>>{};
|
||||
for (final e in events) {
|
||||
final key = dateOnly(e.startDt);
|
||||
(byDay[key] ??= []).add(e);
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import 'api_client_provider.dart';
|
||||
import 'capture_queue_provider.dart';
|
||||
import 'notes_provider.dart';
|
||||
import 'tasks_provider.dart';
|
||||
import 'chat_provider.dart';
|
||||
|
||||
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
|
||||
class CaptureResult {
|
||||
@@ -25,7 +24,6 @@ class _CaptureResultNotifier extends Notifier<CaptureResult?> {
|
||||
}
|
||||
|
||||
/// In-memory sequential work queue for quick captures.
|
||||
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
|
||||
final captureWorkQueueProvider =
|
||||
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||
CaptureWorkQueueNotifier.new,
|
||||
@@ -52,31 +50,24 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
|
||||
// Signal "no result yet" so the same result value can re-trigger watch.
|
||||
ref.read(captureResultProvider.notifier).state = null;
|
||||
try {
|
||||
final api = ref.read(quickCaptureApiProvider);
|
||||
final result = await api.capture(text);
|
||||
// Create a new conversation, add it to the conversations list, then
|
||||
// send the message and kick off generation in the background.
|
||||
final conv =
|
||||
await ref.read(conversationsProvider.notifier).create('');
|
||||
final chatRepo = ref.read(chatRepositoryProvider);
|
||||
await chatRepo.sendMessage(conv.id, text);
|
||||
// Fire-and-forget: drain the SSE stream so the server generates a
|
||||
// response (creating notes/tasks/etc.) without blocking the UI.
|
||||
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
|
||||
|
||||
// Dequeue on success.
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
|
||||
// Invalidate content providers so lists refresh.
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
}
|
||||
|
||||
// Publish result for snackbar.
|
||||
final msg = result.message.isNotEmpty
|
||||
? result.message
|
||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||
ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
|
||||
ref.read(captureResultProvider.notifier).state =
|
||||
const CaptureResult('Sent to Fabled.');
|
||||
} on NetworkException catch (_) {
|
||||
// Persist to offline queue and stop draining — still offline.
|
||||
await ref.read(captureQueueProvider.notifier).enqueue(text);
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
ref.read(captureResultProvider.notifier).state = CaptureResult(
|
||||
ref.read(captureResultProvider.notifier).state = const CaptureResult(
|
||||
"You're offline — capture saved and will retry automatically.",
|
||||
);
|
||||
break;
|
||||
@@ -86,20 +77,13 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
|
||||
CaptureResult(e.message, isError: true);
|
||||
} catch (_) {
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
ref.read(captureResultProvider.notifier).state =
|
||||
CaptureResult('Capture failed. Please try again.', isError: true);
|
||||
ref.read(captureResultProvider.notifier).state = const CaptureResult(
|
||||
'Failed to send. Please try again.',
|
||||
isError: true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'note' => 'Note',
|
||||
'task' => 'Task',
|
||||
'event' => 'Event',
|
||||
'todo' => 'To-do',
|
||||
_ => type,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
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<String> {
|
||||
// ignore: avoid_unused_constructor_parameters
|
||||
_StreamingStatusNotifier(int convId);
|
||||
|
||||
@override
|
||||
String build() => '';
|
||||
}
|
||||
|
||||
final conversationsProvider =
|
||||
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
|
||||
ConversationsNotifier.new);
|
||||
@@ -43,6 +58,12 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
]);
|
||||
}
|
||||
|
||||
/// Re-fetch conversations without clearing the current list (no flicker).
|
||||
Future<void> refresh() async {
|
||||
final fresh = await ref.read(chatRepositoryProvider).getConversations();
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
|
||||
// Called after a message is sent to patch the server-generated title
|
||||
// in-place without triggering a full reload or loading state.
|
||||
void patchConversation(Conversation updated) {
|
||||
@@ -71,6 +92,13 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// Re-fetch messages without clearing the current list (no flicker).
|
||||
Future<void> refresh() async {
|
||||
final (_, messages) =
|
||||
await ref.read(chatRepositoryProvider).getMessages(_convId);
|
||||
state = AsyncData(messages);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final convId = _convId;
|
||||
final repo = ref.read(chatRepositoryProvider);
|
||||
@@ -103,12 +131,19 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||
// ── 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 +192,7 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||
}
|
||||
} finally {
|
||||
ref.read(isStreamingProvider(convId).notifier).state = false;
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int> ids;
|
||||
@@ -113,12 +115,43 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = KnowledgeState(
|
||||
noteType: state.noteType,
|
||||
activeTags: state.activeTags,
|
||||
searchQuery: state.searchQuery,
|
||||
);
|
||||
await _fetchFromScratch();
|
||||
// Keep current items visible during re-fetch (no flicker).
|
||||
try {
|
||||
if (state.noteType == 'task') {
|
||||
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,
|
||||
hasMore: false,
|
||||
);
|
||||
await _loadCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
final (ids, total) = await _repo.fetchIds(
|
||||
noteType: state.noteType,
|
||||
tags: state.activeTags,
|
||||
q: state.searchQuery,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
);
|
||||
// Hydrate the new IDs
|
||||
final batch = await _repo.fetchBatch(ids);
|
||||
final freshItems = {for (final item in batch) item.id: item};
|
||||
state = state.copyWith(
|
||||
ids: ids,
|
||||
items: freshItems,
|
||||
totalIds: total,
|
||||
hasMore: ids.length < total,
|
||||
);
|
||||
await Future.wait([_loadCounts(), _loadTags()]);
|
||||
} catch (_) {
|
||||
// Silent — stale data is better than an error on refresh
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scroll-triggered loaders ─────────────────────────────────────────────
|
||||
@@ -150,6 +183,23 @@ class KnowledgeNotifier extends Notifier<KnowledgeState> {
|
||||
Future<void> _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,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/briefing_feed.dart';
|
||||
import '../data/models/news_item.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
// ─── NewsState ────────────────────────────────────────────────────────────────
|
||||
|
||||
class NewsState {
|
||||
final List<NewsItem> items;
|
||||
final int offset;
|
||||
final bool hasMore;
|
||||
final bool loadingMore;
|
||||
final int? selectedFeedId;
|
||||
final Map<int, String?> reactions;
|
||||
|
||||
const NewsState({
|
||||
required this.items,
|
||||
required this.offset,
|
||||
required this.hasMore,
|
||||
required this.loadingMore,
|
||||
required this.selectedFeedId,
|
||||
required this.reactions,
|
||||
});
|
||||
|
||||
NewsState copyWith({
|
||||
List<NewsItem>? items,
|
||||
int? offset,
|
||||
bool? hasMore,
|
||||
bool? loadingMore,
|
||||
Object? selectedFeedId = _sentinel,
|
||||
Map<int, String?>? reactions,
|
||||
}) {
|
||||
return NewsState(
|
||||
items: items ?? this.items,
|
||||
offset: offset ?? this.offset,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
selectedFeedId: selectedFeedId == _sentinel
|
||||
? this.selectedFeedId
|
||||
: selectedFeedId as int?,
|
||||
reactions: reactions ?? this.reactions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _sentinel = Object();
|
||||
|
||||
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
|
||||
|
||||
final newsProvider =
|
||||
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
|
||||
|
||||
class NewsNotifier extends AsyncNotifier<NewsState> {
|
||||
static const _limit = 40;
|
||||
|
||||
@override
|
||||
Future<NewsState> build() async {
|
||||
final items = await ref.watch(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},
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-fetch the first page without clearing state (no flicker).
|
||||
Future<void> refresh() async {
|
||||
final current = state.value;
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
feedId: current?.selectedFeedId,
|
||||
);
|
||||
state = AsyncData(NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: current?.selectedFeedId,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.loadingMore || !current.hasMore) return;
|
||||
state = AsyncData(current.copyWith(loadingMore: true));
|
||||
try {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: current.offset,
|
||||
feedId: current.selectedFeedId,
|
||||
);
|
||||
final updatedReactions = Map<int, String?>.from(current.reactions);
|
||||
for (final item in items) {
|
||||
updatedReactions.putIfAbsent(item.id, () => item.reaction);
|
||||
}
|
||||
state = AsyncData(current.copyWith(
|
||||
items: [...current.items, ...items],
|
||||
offset: current.offset + items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
reactions: updatedReactions,
|
||||
));
|
||||
} catch (e) {
|
||||
final recovered = state.value ?? current;
|
||||
state = AsyncData(recovered.copyWith(loadingMore: false));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setFeed(int? feedId) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
feedId: feedId,
|
||||
);
|
||||
state = AsyncData(NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: feedId,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleReaction(int itemId, String reaction) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final prev = current.reactions[itemId];
|
||||
final next = prev == reaction ? null : reaction;
|
||||
state = AsyncData(current.copyWith(
|
||||
reactions: {...current.reactions, itemId: next},
|
||||
));
|
||||
final briefingApi = ref.read(briefingApiProvider);
|
||||
final future = next == null
|
||||
? briefingApi.deleteRssReaction(itemId)
|
||||
: briefingApi.postRssReaction(itemId, next);
|
||||
future.catchError((_) {
|
||||
final s = state.value;
|
||||
if (s != null) {
|
||||
state = AsyncData(s.copyWith(
|
||||
reactions: {...s.reactions, itemId: prev},
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
|
||||
|
||||
final feedsProvider =
|
||||
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
|
||||
|
||||
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
|
||||
@override
|
||||
Future<List<BriefingFeed>> build() async {
|
||||
return ref.watch(newsApiProvider).getFeeds();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
.getAll(sort: 'updated_at', order: 'desc');
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final fresh = await ref.read(projectsRepositoryProvider)
|
||||
.getAll(sort: 'updated_at', order: 'desc');
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
|
||||
Future<Project> create({
|
||||
required String title,
|
||||
String? description,
|
||||
|
||||
@@ -17,6 +17,11 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
return ref.watch(tasksRepositoryProvider).getAll();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final fresh = await ref.read(tasksRepositoryProvider).getAll();
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
|
||||
Future<Task> create({
|
||||
required String title,
|
||||
String? description,
|
||||
|
||||
@@ -100,6 +100,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
|
||||
// Voice mode callbacks
|
||||
Future<void> Function(String transcript)? _onTranscript;
|
||||
void Function(String message)? _onError;
|
||||
bool _enableTts = false;
|
||||
|
||||
// Streaming TTS state
|
||||
@@ -107,6 +108,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
int _lastSeenLength = 0;
|
||||
bool _streamComplete = false;
|
||||
|
||||
// Last complete assistant response — passed to Whisper as initial_prompt
|
||||
// to reduce STT mishearings of domain-specific words.
|
||||
String _lastAssistantContent = '';
|
||||
|
||||
// TTS playback queue
|
||||
final _ttsQueue = Queue<Uint8List>();
|
||||
bool _ttsPlaying = false;
|
||||
@@ -165,6 +170,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
}
|
||||
|
||||
_onTranscript = onTranscript;
|
||||
_onError = onError;
|
||||
_enableTts = enableTts;
|
||||
_tempDir = await getTemporaryDirectory();
|
||||
|
||||
@@ -184,6 +190,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
_lastSeenLength = 0;
|
||||
_streamComplete = false;
|
||||
_onTranscript = null;
|
||||
_onError = null;
|
||||
state = const VoiceState();
|
||||
}
|
||||
|
||||
@@ -202,6 +209,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
_dispatchSentences(flush: isComplete);
|
||||
|
||||
if (isComplete) {
|
||||
_lastAssistantContent = fullContent;
|
||||
_streamComplete = true;
|
||||
_checkRestartListening();
|
||||
}
|
||||
@@ -217,18 +225,25 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
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 +253,12 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
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();
|
||||
@@ -263,8 +283,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
|
||||
if (!state.voiceModeActive) return;
|
||||
|
||||
final transcript =
|
||||
await ref.read(voiceRepositoryProvider).transcribe(bytes);
|
||||
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
|
||||
bytes,
|
||||
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
|
||||
);
|
||||
|
||||
if (!state.voiceModeActive) return;
|
||||
|
||||
@@ -289,8 +311,8 @@ class VoiceNotifier extends Notifier<VoiceState> {
|
||||
if (!_enableTts && state.voiceModeActive) {
|
||||
await _startListening();
|
||||
}
|
||||
} catch (_) {
|
||||
// Network/API error — exit voice mode
|
||||
} catch (e) {
|
||||
_onError?.call('Voice error: transcription failed');
|
||||
exitVoiceMode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,23 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _handleReaction(int itemId, String reaction) async {
|
||||
final current = _reactions[itemId];
|
||||
final next = current == reaction ? null : reaction;
|
||||
@@ -268,8 +285,10 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
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<BriefingScreen>
|
||||
/// and RSS reaction buttons below it (for assistant messages with metadata).
|
||||
class _BriefingMessageItem extends StatelessWidget {
|
||||
final Message message;
|
||||
final int convId;
|
||||
final Map<int, String?> 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<String, dynamic>? : null;
|
||||
|
||||
// RSS news cards
|
||||
// RSS news cards — cap at 3
|
||||
final rssItemsRaw = isAssistant && meta != null
|
||||
? (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? []
|
||||
: <Map<String, dynamic>>[];
|
||||
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(),
|
||||
),
|
||||
),
|
||||
@@ -438,7 +462,7 @@ class _GradientSendButton extends StatelessWidget {
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
|
||||
),
|
||||
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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<CalendarEvent>(
|
||||
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: RefreshIndicator(
|
||||
onRefresh: () => ref.read(calendarProvider.notifier).refresh(),
|
||||
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<CalendarEvent> events;
|
||||
final CalendarNotifier notifier;
|
||||
|
||||
const _AgendaList({required this.events, required this.notifier});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (events.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(height: 80),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<EventFormSheet> createState() => _EventFormSheetState();
|
||||
}
|
||||
|
||||
class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
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<String?, String> _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<String> _presetColors = [
|
||||
'#EF4444',
|
||||
'#F59E0B',
|
||||
'#10B981',
|
||||
'#7C3AED',
|
||||
'#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<void> _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 = <String, dynamic>{
|
||||
'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<void> _delete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete this event?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(
|
||||
'Delete',
|
||||
style: TextStyle(
|
||||
color: Theme.of(dialogContext).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<void> _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<void> _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<void> _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<void> _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<String?>(
|
||||
value: _recurrence,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: _knownRrules.entries
|
||||
.map((entry) => DropdownMenuItem<String?>(
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,27 @@ class ChatScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.read(messagesProvider(widget.conversationId).notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
// Exit voice mode if the user navigates away mid-session.
|
||||
@@ -89,6 +104,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
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 +156,13 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
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
|
||||
: '',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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}'));
|
||||
@@ -64,15 +64,17 @@ class ConversationsTabScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(conversationsProvider),
|
||||
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
itemCount: convs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
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,
|
||||
@@ -97,15 +99,15 @@ class ConversationsTabScreen extends ConsumerWidget {
|
||||
BuildContext context, WidgetRef ref, int id, String title) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text('"$title" will be permanently deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -74,15 +74,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
});
|
||||
}
|
||||
|
||||
String _tabLabel(int index, Map<String, int> 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<KnowledgeScreen>
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: List.generate(
|
||||
_kTabs.length,
|
||||
(i) => Tab(text: _tabLabel(i, state.counts)),
|
||||
(i) => Tab(text: _tabLabel(i)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -48,12 +48,18 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (hex == null || hex.isEmpty) return const Color(0xFF7C3AED);
|
||||
try {
|
||||
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||
} catch (_) {
|
||||
return const Color(0xFF6366F1);
|
||||
return const Color(0xFF7C3AED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +151,10 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
setState(() => _pendingStatus.clear());
|
||||
ref.invalidate(projectTasksProvider(widget.projectId));
|
||||
ref.invalidate(projectMilestonesProvider(widget.projectId));
|
||||
await Future.wait([
|
||||
ref.refresh(projectTasksProvider(widget.projectId).future),
|
||||
ref.refresh(projectMilestonesProvider(widget.projectId).future),
|
||||
]);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -171,6 +179,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
onTap: () => _openTask(task.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -198,6 +207,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
onTap: () => _openTask(task.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -282,11 +292,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 +329,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),
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../data/models/briefing_feed.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/news_provider.dart';
|
||||
import '../../widgets/news_card.dart';
|
||||
|
||||
class NewsScreen extends ConsumerStatefulWidget {
|
||||
const NewsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NewsScreen> createState() => _NewsScreenState();
|
||||
}
|
||||
|
||||
class _NewsScreenState extends ConsumerState<NewsScreen> {
|
||||
final Set<int> _openingChat = {};
|
||||
|
||||
Future<void> _handleDiscuss(int itemId) async {
|
||||
if (_openingChat.contains(itemId)) return;
|
||||
setState(() => _openingChat.add(itemId));
|
||||
try {
|
||||
final conversationId =
|
||||
await ref.read(chatApiProvider).openArticleInChat(itemId);
|
||||
if (mounted) {
|
||||
context.push(Routes.chat.replaceFirst(':id', '$conversationId'));
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to open article in chat.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _openingChat.remove(itemId));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
try {
|
||||
await ref.read(newsProvider.notifier).loadMore();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to load more articles.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final newsAsync = ref.watch(newsProvider);
|
||||
final feedsAsync = ref.watch(feedsProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('News', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
'Last 90 days',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: newsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (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: RefreshIndicator(
|
||||
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
itemCount: news.items.length + 1,
|
||||
itemBuilder: (_, i) {
|
||||
if (i == news.items.length) {
|
||||
if (!news.hasMore) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(
|
||||
child: news.loadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: FilledButton.tonal(
|
||||
onPressed: _loadMore,
|
||||
child: const Text('Load more'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final item = news.items[i];
|
||||
return NewsCard(
|
||||
item: RssItemMeta.fromNewsItem(item),
|
||||
reaction: news.reactions[item.id],
|
||||
onReaction: (itemId, reaction) => ref
|
||||
.read(newsProvider.notifier)
|
||||
.toggleReaction(itemId, reaction),
|
||||
onDiscuss: _openingChat.contains(item.id)
|
||||
? null
|
||||
: () => _handleDiscuss(item.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedFilter extends StatelessWidget {
|
||||
final List<BriefingFeed> feeds;
|
||||
final int? selectedFeedId;
|
||||
final void Function(int? feedId) onChanged;
|
||||
|
||||
const _FeedFilter({
|
||||
required this.feeds,
|
||||
required this.selectedFeedId,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Feed:',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DropdownButton<int?>(
|
||||
value: selectedFeedId,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('All feeds'),
|
||||
),
|
||||
...feeds.map(
|
||||
(f) => DropdownMenuItem<int?>(
|
||||
value: f.id,
|
||||
child: Text(f.title),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => onChanged(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class ProjectsScreen extends ConsumerWidget {
|
||||
data: (projects) => projects.isEmpty
|
||||
? const Center(child: Text('No projects yet.'))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(projectsProvider),
|
||||
onRefresh: () => ref.read(projectsProvider.notifier).refresh(),
|
||||
child: ListView.separated(
|
||||
itemCount: projects.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> _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;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <flutter_timezone/flutter_timezone_plugin.h>
|
||||
#include <open_file_linux/open_file_linux_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_timezone
|
||||
open_file_linux
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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';
|
||||
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 +149,131 @@ 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': <dynamic>[],
|
||||
'reaction': null,
|
||||
};
|
||||
final item = NewsItem.fromJson(json);
|
||||
expect(item.publishedAt, isNull);
|
||||
expect(item.reaction, isNull);
|
||||
expect(item.topics, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
'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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_inappwebview_windows
|
||||
flutter_timezone
|
||||
permission_handler_windows
|
||||
record_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user