From ac4b2359a5c029392adae3283c31e75f12479172 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 22:30:44 -0400 Subject: [PATCH 01/24] docs: add Knowledge View design spec for Android app Covers four-tab navigation (Briefing/Knowledge/Chat/Projects), two-tier pagination (50 IDs / 12-item batches), type-aware cards, bottom sheet type picker, Projects screen with sorted project list, ProjectEditScreen, and manifest/config checklist. Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-04-04-knowledge-view-design.md | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-04-knowledge-view-design.md diff --git a/docs/superpowers/specs/2026-04-04-knowledge-view-design.md b/docs/superpowers/specs/2026-04-04-knowledge-view-design.md new file mode 100644 index 0000000..1da61e5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-knowledge-view-design.md @@ -0,0 +1,455 @@ +# Knowledge View — Android App Design Spec + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the Library tab with a typed Knowledge view and add a dedicated Projects tab, bringing the Android app to parity with the web Knowledge view while using the existing backend `/api/knowledge` and `/api/projects` endpoints. + +**Architecture:** Two-tier pagination — fetch IDs cheaply (50 at a time), hydrate visible items in batches of 12. Type tabs drive server-side filtering. A new Projects tab replaces the project section formerly in Library. All data access goes through the existing Riverpod/Dio/repository pattern. + +**Tech Stack:** Flutter 3, Riverpod 3, GoRouter 17, Dio 5, existing backend REST API (`/api/knowledge`, `/api/projects`, `/api/notes`) + +--- + +## Manifest & Configuration Checklist + +These must be verified before any code is written. Failures here cause silent runtime errors. + +- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `` is present +- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `android:usesCleartextTraffic="true"` is set on the `` tag (required for HTTP dev server connections; if already using HTTPS only, leave as-is but document the decision) +- [ ] `pubspec.yaml` — no new dependencies required for Knowledge View +- [ ] `android/app/build.gradle` — no changes required for Knowledge View + +--- + +## File Map + +### New files +| Path | Responsibility | +|---|---| +| `lib/data/models/knowledge_item.dart` | Unified model for all knowledge types (note, person, place, list, task) | +| `lib/data/api/knowledge_api.dart` | Two API methods: fetch IDs, batch-hydrate items | +| `lib/data/repositories/knowledge_repository.dart` | Thin repo wrapping KnowledgeApi | +| `lib/providers/knowledge_provider.dart` | StateNotifier with two-tier pagination state | +| `lib/screens/knowledge/knowledge_screen.dart` | Main Knowledge screen with type tabs, tag chips, search, infinite scroll | +| `lib/widgets/knowledge_item_card.dart` | Type-aware card widget (different icon/subtitle per type) | +| `lib/screens/projects/projects_screen.dart` | Project list sorted by updated_at desc | +| `lib/screens/projects/project_edit_screen.dart` | Create/edit project (title, description, goal, status) | + +### Modified files +| Path | Change | +|---|---| +| `lib/data/models/note.dart` | Add `noteType` field (String, default `'note'`) | +| `lib/data/models/project.dart` | Add `status`, `goal`, `color`, `autoSummary`, `updatedAt` fields | +| `lib/data/api/notes_api.dart` | Pass `note_type` in create and update request bodies | +| `lib/data/api/projects_api.dart` | Add `sort=updated_at&order=desc` to list call; add `status`, `goal`, `color` to create/update | +| `lib/screens/notes/note_edit_screen.dart` | Accept optional `noteType` parameter; show type badge in app bar; pass `note_type` to API | +| `lib/screens/library/project_tasks_screen.dart` | Add edit button in app bar pushing to `ProjectEditScreen` | +| `lib/app.dart` | Replace `Routes.library` with `Routes.knowledge` + `Routes.projects`; update shell to 4 tabs | +| `lib/core/constants.dart` | Add `Routes.knowledge`, `Routes.projects`; remove `Routes.library` | + +### Retired files +| Path | Replacement | +|---|---| +| `lib/screens/library/library_screen.dart` | `KnowledgeScreen` + `ProjectsScreen` | +| `lib/widgets/library_item_card.dart` | `KnowledgeItemCard` | + +--- + +## Section 1: Navigation + +Four tabs replace the existing three: + +``` +Briefing | Knowledge | Chat | Projects +``` + +**`lib/core/constants.dart`:** +- Remove `static const library = '/library'` +- Add `static const knowledge = '/knowledge'` +- `projects` already exists as `'/projects'` — no change needed +- Add `static const projectEdit = '/projects/:id/edit'` + +**`lib/app.dart` — `_ShellState`:** +```dart +static const _tabs = [ + Routes.briefing, + Routes.knowledge, + Routes.conversations, + Routes.projects, +]; +``` + +Shell `NavigationBar` / `NavigationRail` entries: +```dart +// Bottom nav +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'), +``` + +**`_QuickCaptureBar._hintForLocation` update:** +```dart +String _hintForLocation(String location) { + if (location.startsWith(Routes.knowledge)) return 'Capture a note…'; + if (location.startsWith(Routes.projects)) return 'Capture a note…'; + if (location.startsWith(Routes.conversations)) return 'Ask Fabled…'; + return 'Capture a note…'; +} +``` + +**GoRouter shell routes** — replace `Routes.library` route with: +```dart +GoRoute(path: Routes.knowledge, builder: (_, _) => const KnowledgeScreen()), +GoRoute(path: Routes.projects, builder: (_, _) => const ProjectsScreen()), +``` + +--- + +## Section 2: Data Models + +### `lib/data/models/knowledge_item.dart` +```dart +class KnowledgeItem { + final int id; + final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task' + final String title; + final String body; + final List tags; + final int? projectId; + final int? milestoneId; + final int? parentId; + // Task-only fields (null for non-tasks) + final String? status; // 'todo' | 'in_progress' | 'done' | 'cancelled' + final String? priority; // 'low' | 'normal' | 'high' + final String? dueDate; + final DateTime createdAt; + final DateTime updatedAt; + + const KnowledgeItem({...}); + + factory KnowledgeItem.fromJson(Map json) => KnowledgeItem( + id: json['id'] as int, + noteType: json['note_type'] as String? ?? 'note', + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + tags: (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], + projectId: json['project_id'] as int?, + milestoneId: json['milestone_id'] as int?, + parentId: json['parent_id'] as int?, + status: json['status'] as String?, + priority: json['priority'] as String?, + dueDate: json['due_date'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); +} +``` + +### `lib/data/models/note.dart` — add `noteType` +```dart +final String noteType; // new field + +// In constructor: +required this.noteType, + +// In fromJson: +noteType: json['note_type'] as String? ?? 'note', + +// In toJson: +'note_type': noteType, + +// In copyWith: add noteType parameter +``` + +### `lib/data/models/project.dart` — add missing fields +```dart +final String status; // 'active' | 'completed' | 'archived' +final String? goal; +final String? color; +final String? autoSummary; +final DateTime updatedAt; + +// In fromJson: +status: json['status'] as String? ?? 'active', +goal: json['goal'] as String?, +color: json['color'] as String?, +autoSummary: json['auto_summary'] as String?, +updatedAt: DateTime.parse(json['updated_at'] as String), +``` + +--- + +## Section 3: API Layer + +### `lib/data/api/knowledge_api.dart` +```dart +class KnowledgeApi { + final Dio _dio; + const KnowledgeApi(this._dio); + + /// Fetch page of IDs. Returns (ids, total). + Future<(List, int)> fetchIds({ + String? noteType, + List tags = const [], + String sort = 'modified', + String? q, + int limit = 50, + int offset = 0, + }) async { + final params = { + 'limit': limit, + 'offset': offset, + 'sort': sort, + if (noteType != null) 'type': noteType, + if (tags.isNotEmpty) 'tags': tags.join(','), + if (q != null && q.isNotEmpty) 'q': q, + }; + final response = await _dio.get('/api/knowledge/ids', queryParameters: params); + final data = response.data as Map; + final ids = (data['ids'] as List).map((e) => e as int).toList(); + final total = data['total'] as int; + return (ids, total); + } + + /// Batch-hydrate up to 100 IDs into full items. + Future> fetchBatch(List ids) async { + if (ids.isEmpty) return []; + final response = await _dio.get( + '/api/knowledge/batch', + queryParameters: {'ids': ids.join(',')}, + ); + final data = response.data as Map; + return (data['items'] as List) + .map((e) => KnowledgeItem.fromJson(e as Map)) + .toList(); + } + + /// Per-type counts for tab labels. + Future> fetchCounts({List tags = const []}) async { + final params = { + if (tags.isNotEmpty) 'tags': tags.join(','), + }; + final response = await _dio.get('/api/knowledge/counts', queryParameters: params); + return (response.data as Map).map( + (k, v) => MapEntry(k, (v as num).toInt()), + ); + } + + /// All tags for the current type filter. + Future> fetchTags({String? noteType}) async { + final response = await _dio.get( + '/api/knowledge/tags', + queryParameters: {if (noteType != null) 'type': noteType}, + ); + return (response.data['tags'] as List).map((e) => e as String).toList(); + } +} +``` + +### `lib/data/api/projects_api.dart` — add sort params + missing fields +Add `sort: 'updated_at'` and `order: 'desc'` to the `getProjects` query parameters. +Add `status`, `goal`, `color` to `createProject` and `updateProject` request bodies. + +### `lib/data/api/notes_api.dart` — pass `note_type` +```dart +// In createNote and updateNote bodies: +if (noteType != null) 'note_type': noteType, +``` + +--- + +## Section 4: Provider + +### `lib/providers/knowledge_provider.dart` + +```dart +@immutable +class KnowledgeState { + final List ids; // all fetched IDs so far + final Map items; // hydrated items + final int totalIds; + final bool isLoadingIds; + final bool isLoadingBatch; + final bool hasMore; + final String? noteType; // active type filter + final List activeTags; + final String? searchQuery; + final Map counts; // per-type counts + + bool get canLoadMore => hasMore && !isLoadingIds; + // Items in ID order, only those hydrated + List get orderedItems => + ids.where(items.containsKey).map((id) => items[id]!).toList(); +} + +class KnowledgeNotifier extends StateNotifier { + // On filter change: reset state, fetch IDs from offset 0 + void setTypeFilter(String? noteType) { ... } + void toggleTag(String tag) { ... } + void setSearch(String? q) { ... } // debounce handled in UI + + // Fetch next page of IDs (50 at a time) + Future loadMoreIds() { ... } + + // Hydrate the next 12 un-hydrated IDs from the current list + Future hydrateNext() { ... } + + Future refresh() { ... } // reset + reload +} + +// Provider +final knowledgeProvider = + StateNotifierProvider(...); +``` + +**Scroll trigger:** `KnowledgeScreen` attaches a `ScrollController` listener. When `position.pixels >= maxScrollExtent - 300`: +1. Call `hydrateNext()` if there are un-hydrated IDs in the list +2. Call `loadMoreIds()` if all fetched IDs are hydrated and `hasMore` is true + +--- + +## Section 5: Knowledge Screen + +### `lib/screens/knowledge/knowledge_screen.dart` + +**Structure:** +``` +Scaffold + AppBar + title: 'Knowledge' + actions: [search icon → expand TextField] + Column + TabBar (All | Notes | People | Places | Lists | Tasks) + — tab labels show counts: "Notes (12)" + AnimatedContainer (tag filter chip row, hidden when empty) + — horizontal SingleChildScrollView of FilterChip widgets + Expanded + ListView.builder + — items from knowledgeState.orderedItems + — trailing: loading indicator when isLoadingBatch + FAB (pen icon) + — Tasks tab → push TaskEditScreen + — all other tabs → showModalBottomSheet (type picker) +``` + +**Pull-to-refresh:** `RefreshIndicator` wrapping the `ListView`. + +**Empty state:** Centered column with type-appropriate icon and "No [type] yet" text. + +**Error state:** Centered text with retry button calling `ref.invalidate(knowledgeProvider)`. + +### `lib/widgets/knowledge_item_card.dart` + +`ListTile`-based card. Leading icon varies by type: +- note → `Icons.description_outlined` +- person → `Icons.person_outlined` +- place → `Icons.place_outlined` +- list → `Icons.checklist_outlined` +- task → `Icons.task_alt_outlined` (with status colour on leading) + +Subtitle shows truncated body (max 2 lines) or due date for tasks. +Trailing shows tag chips (up to 2, then "+N more"). + +Tap → `NoteDetailScreen(noteId: item.id)` for knowledge types; `TaskEditScreen(taskId: item.id)` for tasks. + +--- + +## Section 6: Type Picker Bottom Sheet + +Shown when FAB is tapped on any non-Tasks tab. + +```dart +showModalBottomSheet( + context: context, + builder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TypeRow(Icons.description_outlined, 'Note', 'General note or document', 'note'), + _TypeRow(Icons.person_outlined, 'Person', 'Contact, colleague, or reference person', 'person'), + _TypeRow(Icons.place_outlined, 'Place', 'Location, venue, or place of interest', 'place'), + _TypeRow(Icons.checklist_outlined, 'List', 'Checklist or structured list', 'list'), + ], + ), +); +``` + +Each `_TypeRow` on tap: +1. `Navigator.pop(context)` +2. `context.push(Routes.noteNew, extra: {'noteType': selectedType})` + +### `lib/screens/notes/note_edit_screen.dart` changes +- Accept `noteType` from `GoRouterState.extra` or route parameter (default `'note'`) +- Display a read-only type badge chip in the app bar subtitle +- Pass `noteType` to `notes_api.createNote` / `notes_api.updateNote` + +--- + +## Section 7: Projects Screen + +### `lib/screens/projects/projects_screen.dart` + +``` +Scaffold + AppBar: 'Projects' + RefreshIndicator + ListView.builder + — projects from projectsProvider (sorted updated_at desc via API param) + — each item: _ProjectCard + FAB → push ProjectEditScreen() +``` + +`_ProjectCard` (inline widget): +- Title + status badge (`active` = green, `completed` = blue, `archived` = grey) +- Description (1 line, truncated) +- Goal text if present (italic, muted) +- Milestone progress: `"3 / 5 milestones done"` using milestone count from project data if available, otherwise omitted + +Tap → `context.push('/projects/${project.id}/tasks')` — `ProjectTasksScreen` reads `projectId` from the path parameter, unchanged except for the added edit button. + +### `lib/screens/projects/project_edit_screen.dart` + +Fields: Title (required), Description, Goal, Status dropdown (`active` / `completed` / `archived`). +Used for both create (no `projectId`) and edit (with `projectId`). +On save: POST `/api/projects` or PATCH `/api/projects/:id` via `projectsApi`. +On success: `ref.invalidate(projectsProvider)` then `Navigator.pop`. + +### `lib/screens/library/project_tasks_screen.dart` change +Add an edit `IconButton` in the `AppBar.actions`: +```dart +IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => context.push('/projects/$projectId/edit'), +) +``` + +--- + +## Section 8: Provider & API Wiring + +New providers to add to `lib/providers/`: + +```dart +// knowledge_api_provider.dart (or in api_client_provider.dart) +final knowledgeApiProvider = Provider((ref) => + KnowledgeApi(ref.watch(dioProvider))); + +final knowledgeRepositoryProvider = Provider((ref) => + KnowledgeRepository(ref.watch(knowledgeApiProvider))); +``` + +`projectsProvider` — add query parameters to the underlying `getProjects` call: +```dart +await api.getProjects(sort: 'updated_at', order: 'desc'); +``` + +--- + +## What This Does NOT Include + +- Voice I/O (separate spec) +- Backend parity pass (separate spec) +- Editing knowledge item type after creation +- Bulk delete / multi-select in Knowledge screen +- Knowledge graph view +- Milestone detail screen (projects show milestone count only) From e89626a782650063e6e66b33699c68c11b6108fc Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 22:55:50 -0400 Subject: [PATCH 02/24] feat(knowledge): add knowledge + projectEdit route constants --- lib/core/constants.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 9d84bf1..bf4b3d1 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -9,12 +9,13 @@ abstract class Routes { 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 library = '/library'; static const projectTasks = '/projects/:id/tasks'; } From c8cdcbf230b321c0fb9f74182688c72f0b6e8d22 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:37:53 -0400 Subject: [PATCH 03/24] feat(knowledge): add noteType field to Note model, api, repository, provider --- lib/data/api/notes_api.dart | 4 +++ lib/data/models/note.dart | 6 ++++ lib/data/repositories/notes_repository.dart | 10 +++++-- lib/providers/notes_provider.dart | 12 ++++++-- test/widget_test.dart | 31 +++++++++++++++++++-- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/lib/data/api/notes_api.dart b/lib/data/api/notes_api.dart index 048cdf7..0f3dfd1 100644 --- a/lib/data/api/notes_api.dart +++ b/lib/data/api/notes_api.dart @@ -32,12 +32,14 @@ class NotesApi { String body, { List tags = const [], int? projectId, + String noteType = 'note', }) async { try { final response = await _dio.post('/api/notes', data: { 'title': title, 'body': body, 'tags': tags, + 'note_type': noteType, if (projectId != null) 'project_id': projectId, }); return Note.fromJson(response.data as Map); @@ -53,12 +55,14 @@ class NotesApi { List tags = const [], int? projectId, bool clearProject = false, + String noteType = 'note', }) async { try { final response = await _dio.put('/api/notes/$id', data: { 'title': title, 'body': body, 'tags': tags, + 'note_type': noteType, if (clearProject) 'project_id': null else if (projectId != null) 'project_id': projectId, }); return Note.fromJson(response.data as Map); diff --git a/lib/data/models/note.dart b/lib/data/models/note.dart index 4f36379..7e1218d 100644 --- a/lib/data/models/note.dart +++ b/lib/data/models/note.dart @@ -3,6 +3,7 @@ class Note { final String title; final String body; final List tags; + final String noteType; final int? projectId; final int? milestoneId; final DateTime createdAt; @@ -13,6 +14,7 @@ class Note { required this.title, required this.body, required this.tags, + this.noteType = 'note', this.projectId, this.milestoneId, required this.createdAt, @@ -27,6 +29,7 @@ class Note { ?.map((e) => e as String) .toList() ?? [], + noteType: json['note_type'] as String? ?? 'note', projectId: json['project_id'] as int?, milestoneId: json['milestone_id'] as int?, createdAt: DateTime.parse(json['created_at'] as String), @@ -37,6 +40,7 @@ class Note { 'title': title, 'body': body, 'tags': tags, + 'note_type': noteType, 'project_id': projectId, 'milestone_id': milestoneId, }; @@ -45,6 +49,7 @@ class Note { String? title, String? body, List? tags, + String? noteType, Object? projectId = _undefined, Object? milestoneId = _undefined, }) => @@ -53,6 +58,7 @@ class Note { title: title ?? this.title, body: body ?? this.body, tags: tags ?? this.tags, + noteType: noteType ?? this.noteType, projectId: identical(projectId, _undefined) ? this.projectId : projectId as int?, diff --git a/lib/data/repositories/notes_repository.dart b/lib/data/repositories/notes_repository.dart index 6fb1b44..681d4f8 100644 --- a/lib/data/repositories/notes_repository.dart +++ b/lib/data/repositories/notes_repository.dart @@ -13,8 +13,10 @@ class NotesRepository { String body, { List tags = const [], int? projectId, + String noteType = 'note', }) => - _api.create(title, body, tags: tags, projectId: projectId); + _api.create(title, body, + tags: tags, projectId: projectId, noteType: noteType); Future update( int id, @@ -23,9 +25,13 @@ class NotesRepository { List tags = const [], int? projectId, bool clearProject = false, + String noteType = 'note', }) => _api.update(id, title, body, - tags: tags, projectId: projectId, clearProject: clearProject); + tags: tags, + projectId: projectId, + clearProject: clearProject, + noteType: noteType); Future delete(int id) => _api.delete(id); } diff --git a/lib/providers/notes_provider.dart b/lib/providers/notes_provider.dart index fcbaa14..f611158 100644 --- a/lib/providers/notes_provider.dart +++ b/lib/providers/notes_provider.dart @@ -17,10 +17,14 @@ class NotesNotifier extends AsyncNotifier> { String body, { List tags = const [], int? projectId, + String noteType = 'note', }) async { - final note = await ref - .read(notesRepositoryProvider) - .create(title, body, tags: tags, projectId: projectId); + final note = await ref.read(notesRepositoryProvider).create( + title, body, + tags: tags, + projectId: projectId, + noteType: noteType, + ); state = AsyncData([...state.value ?? [], note]); return note; } @@ -32,6 +36,7 @@ class NotesNotifier extends AsyncNotifier> { List tags = const [], int? projectId, bool clearProject = false, + String noteType = 'note', }) async { final updated = await ref.read(notesRepositoryProvider).update( id, @@ -40,6 +45,7 @@ class NotesNotifier extends AsyncNotifier> { tags: tags, projectId: projectId, clearProject: clearProject, + noteType: noteType, ); state = AsyncData([ for (final n in state.value ?? []) diff --git a/test/widget_test.dart b/test/widget_test.dart index 2ebd3e2..ae0761c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,8 +1,33 @@ -// Placeholder — integration tests go here once the app is running on device. +import 'package:fabled_app/data/models/note.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('placeholder', () { - expect(true, isTrue); + group('Note.fromJson', () { + test('parses noteType when present', () { + final json = { + 'id': 1, + 'title': 'Alice', + 'body': '', + 'tags': [], + 'note_type': 'person', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('person')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 2, + 'title': 'My note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('note')); + }); }); } From 2920252f134d0fbf74873ea2fb944adcacb1d278 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:38:19 -0400 Subject: [PATCH 04/24] feat(knowledge): add KnowledgeItem model --- lib/data/models/knowledge_item.dart | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 lib/data/models/knowledge_item.dart diff --git a/lib/data/models/knowledge_item.dart b/lib/data/models/knowledge_item.dart new file mode 100644 index 0000000..d8ff992 --- /dev/null +++ b/lib/data/models/knowledge_item.dart @@ -0,0 +1,51 @@ +class KnowledgeItem { + final int id; + final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task' + final String title; + final String body; + final List tags; + final int? projectId; + final int? milestoneId; + final int? parentId; + // Task-only fields (null for non-tasks) + final String? status; // 'todo' | 'in_progress' | 'done' | 'cancelled' + final String? priority; // 'low' | 'normal' | 'high' + final String? dueDate; + final DateTime createdAt; + final DateTime updatedAt; + + const KnowledgeItem({ + required this.id, + required this.noteType, + required this.title, + required this.body, + required this.tags, + this.projectId, + this.milestoneId, + this.parentId, + this.status, + this.priority, + this.dueDate, + required this.createdAt, + required this.updatedAt, + }); + + factory KnowledgeItem.fromJson(Map json) => KnowledgeItem( + id: json['id'] as int, + noteType: json['note_type'] as String? ?? 'note', + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + tags: (json['tags'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + projectId: json['project_id'] as int?, + milestoneId: json['milestone_id'] as int?, + parentId: json['parent_id'] as int?, + status: json['status'] as String?, + priority: json['priority'] as String?, + dueDate: json['due_date'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); +} From deec2318f7ec4165bc535dd2a43555319c20b781 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:39:43 -0400 Subject: [PATCH 05/24] feat(knowledge): add KnowledgeApi, KnowledgeRepository, wire providers --- lib/data/api/knowledge_api.dart | 86 +++++++++++++++++++ .../repositories/knowledge_repository.dart | 33 +++++++ lib/providers/api_client_provider.dart | 10 +++ 3 files changed, 129 insertions(+) create mode 100644 lib/data/api/knowledge_api.dart create mode 100644 lib/data/repositories/knowledge_repository.dart diff --git a/lib/data/api/knowledge_api.dart b/lib/data/api/knowledge_api.dart new file mode 100644 index 0000000..71e84f7 --- /dev/null +++ b/lib/data/api/knowledge_api.dart @@ -0,0 +1,86 @@ +import 'package:dio/dio.dart'; + +import '../models/knowledge_item.dart'; +import 'api_client.dart'; + +class KnowledgeApi { + final Dio _dio; + const KnowledgeApi(this._dio); + + /// Fetch a page of IDs. Returns (ids, total). + Future<(List, int)> fetchIds({ + String? noteType, + List tags = const [], + String sort = 'modified', + String? q, + int limit = 50, + int offset = 0, + }) async { + try { + final params = { + 'limit': limit, + 'offset': offset, + 'sort': sort, + if (noteType != null) 'type': noteType, + if (tags.isNotEmpty) 'tags': tags.join(','), + if (q != null && q.isNotEmpty) 'q': q, + }; + final response = + await _dio.get('/api/knowledge/ids', queryParameters: params); + final data = response.data as Map; + final ids = + (data['ids'] as List).map((e) => e as int).toList(); + final total = data['total'] as int; + return (ids, total); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// Batch-hydrate up to 100 IDs into full items. + Future> fetchBatch(List ids) async { + if (ids.isEmpty) return []; + try { + final response = await _dio.get( + '/api/knowledge/batch', + queryParameters: {'ids': ids.join(',')}, + ); + final data = response.data as Map; + return (data['items'] as List) + .map((e) => KnowledgeItem.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// Per-type counts for tab labels. + Future> fetchCounts({List tags = const []}) async { + try { + final params = { + if (tags.isNotEmpty) 'tags': tags.join(','), + }; + final response = await _dio.get('/api/knowledge/counts', + queryParameters: params); + return (response.data as Map) + .map((k, v) => MapEntry(k, (v as num).toInt())); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// All tags for the current type filter. + Future> fetchTags({String? noteType}) async { + try { + final response = await _dio.get( + '/api/knowledge/tags', + queryParameters: {if (noteType != null) 'type': noteType}, + ); + return (response.data['tags'] as List) + .map((e) => e as String) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/lib/data/repositories/knowledge_repository.dart b/lib/data/repositories/knowledge_repository.dart new file mode 100644 index 0000000..f6264ac --- /dev/null +++ b/lib/data/repositories/knowledge_repository.dart @@ -0,0 +1,33 @@ +import '../api/knowledge_api.dart'; +import '../models/knowledge_item.dart'; + +class KnowledgeRepository { + final KnowledgeApi _api; + const KnowledgeRepository(this._api); + + Future<(List, int)> fetchIds({ + String? noteType, + List tags = const [], + String sort = 'modified', + String? q, + int limit = 50, + int offset = 0, + }) => + _api.fetchIds( + noteType: noteType, + tags: tags, + sort: sort, + q: q, + limit: limit, + offset: offset, + ); + + Future> fetchBatch(List ids) => + _api.fetchBatch(ids); + + Future> fetchCounts({List tags = const []}) => + _api.fetchCounts(tags: tags); + + Future> fetchTags({String? noteType}) => + _api.fetchTags(noteType: noteType); +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 2aba0ce..7ae55e2 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -6,6 +6,7 @@ import '../data/api/api_client.dart'; import '../data/api/auth_api.dart'; import '../data/api/briefing_api.dart'; import '../data/api/chat_api.dart'; +import '../data/api/knowledge_api.dart'; import '../data/api/milestones_api.dart'; import '../data/api/notes_api.dart'; import '../data/api/projects_api.dart'; @@ -14,6 +15,7 @@ import '../data/api/settings_api.dart'; import '../data/api/tasks_api.dart'; import '../data/repositories/auth_repository.dart'; import '../data/repositories/chat_repository.dart'; +import '../data/repositories/knowledge_repository.dart'; import '../data/repositories/milestones_repository.dart'; import '../data/repositories/notes_repository.dart'; import '../data/repositories/projects_repository.dart'; @@ -83,6 +85,14 @@ final milestonesRepositoryProvider = Provider((ref) { return MilestonesRepository(ref.watch(milestonesApiProvider)); }); +final knowledgeApiProvider = Provider((ref) { + return KnowledgeApi(ref.watch(dioProvider)); +}); + +final knowledgeRepositoryProvider = Provider((ref) { + return KnowledgeRepository(ref.watch(knowledgeApiProvider)); +}); + final briefingApiProvider = Provider((ref) { return BriefingApi(ref.watch(dioProvider)); }); From 535833abfeb3a0d769cd82bfee9fd2bc95f11347 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:40:29 -0400 Subject: [PATCH 06/24] test: add KnowledgeItem model unit tests --- test/widget_test.dart | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/widget_test.dart b/test/widget_test.dart index ae0761c..531745d 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/note.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -30,4 +31,39 @@ void main() { expect(note.noteType, equals('note')); }); }); + + group('KnowledgeItem.fromJson', () { + test('parses a task item with task fields', () { + final json = { + 'id': 10, + 'note_type': 'task', + 'title': 'Do the thing', + 'body': '', + 'tags': [], + 'status': 'todo', + 'priority': 'high', + 'due_date': '2025-01-01', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('task')); + expect(item.status, equals('todo')); + expect(item.priority, equals('high')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 11, + 'title': 'A note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('note')); + expect(item.status, isNull); + }); + }); } From e0b56fc1496cbe3a92703d5c364c10331f5fb511 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:48:47 -0400 Subject: [PATCH 07/24] feat(knowledge): add KnowledgeNotifier with two-tier pagination state --- lib/providers/knowledge_provider.dart | 220 ++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 lib/providers/knowledge_provider.dart diff --git a/lib/providers/knowledge_provider.dart b/lib/providers/knowledge_provider.dart new file mode 100644 index 0000000..822f333 --- /dev/null +++ b/lib/providers/knowledge_provider.dart @@ -0,0 +1,220 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/knowledge_item.dart'; +import '../data/repositories/knowledge_repository.dart'; +import 'api_client_provider.dart'; + +/// Immutable state for the Knowledge screen's two-tier paginated feed. +class KnowledgeState { + final List ids; + final Map items; + final int totalIds; + final bool isLoadingIds; + final bool isLoadingBatch; + final bool hasMore; + final String? noteType; // null = All + final List activeTags; + final String? searchQuery; + final Map counts; + final List availableTags; + final String? error; + + const KnowledgeState({ + this.ids = const [], + this.items = const {}, + this.totalIds = 0, + this.isLoadingIds = false, + this.isLoadingBatch = false, + this.hasMore = false, + this.noteType, + this.activeTags = const [], + this.searchQuery, + this.counts = const {}, + this.availableTags = const [], + this.error, + }); + + /// Items in server-defined ID order, only those already hydrated. + List get orderedItems => + ids.where(items.containsKey).map((id) => items[id]!).toList(); + + /// IDs that have been fetched but not yet hydrated. + List get unhydratedIds => + ids.where((id) => !items.containsKey(id)).toList(); + + KnowledgeState copyWith({ + List? ids, + Map? items, + int? totalIds, + bool? isLoadingIds, + bool? isLoadingBatch, + bool? hasMore, + Object? noteType = _keep, + List? activeTags, + Object? searchQuery = _keep, + Map? counts, + List? availableTags, + Object? error = _keep, + }) => + KnowledgeState( + ids: ids ?? this.ids, + items: items ?? this.items, + totalIds: totalIds ?? this.totalIds, + isLoadingIds: isLoadingIds ?? this.isLoadingIds, + isLoadingBatch: isLoadingBatch ?? this.isLoadingBatch, + hasMore: hasMore ?? this.hasMore, + noteType: + identical(noteType, _keep) ? this.noteType : noteType as String?, + activeTags: activeTags ?? this.activeTags, + searchQuery: identical(searchQuery, _keep) + ? this.searchQuery + : searchQuery as String?, + counts: counts ?? this.counts, + availableTags: availableTags ?? this.availableTags, + error: identical(error, _keep) ? this.error : error as String?, + ); + + static const _keep = Object(); +} + +class KnowledgeNotifier extends Notifier { + @override + KnowledgeState build() => const KnowledgeState(); + + KnowledgeRepository get _repo => ref.read(knowledgeRepositoryProvider); + + // ── Filter setters — each resets and re-fetches ────────────────────────── + + Future setTypeFilter(String? noteType) async { + state = KnowledgeState(noteType: noteType, activeTags: state.activeTags); + await _fetchFromScratch(); + } + + Future toggleTag(String tag) async { + final tags = state.activeTags.contains(tag) + ? state.activeTags.where((t) => t != tag).toList() + : [...state.activeTags, tag]; + state = KnowledgeState( + noteType: state.noteType, + activeTags: tags, + searchQuery: state.searchQuery, + ); + await _fetchFromScratch(); + } + + Future setSearch(String? q) async { + final query = (q?.trim().isEmpty ?? true) ? null : q?.trim(); + state = KnowledgeState( + noteType: state.noteType, + activeTags: state.activeTags, + searchQuery: query, + ); + await _fetchFromScratch(); + } + + Future refresh() async { + state = KnowledgeState( + noteType: state.noteType, + activeTags: state.activeTags, + searchQuery: state.searchQuery, + ); + await _fetchFromScratch(); + } + + // ── Scroll-triggered loaders ───────────────────────────────────────────── + + /// Hydrate the next 12 un-hydrated IDs. Call when approaching scroll end. + Future hydrateNext() async { + if (state.isLoadingBatch) return; + final toFetch = state.unhydratedIds.take(12).toList(); + if (toFetch.isEmpty) { + // All fetched IDs are hydrated — try loading more IDs. + if (state.hasMore && !state.isLoadingIds) await _loadMoreIds(); + return; + } + state = state.copyWith(isLoadingBatch: true); + try { + final fetched = await _repo.fetchBatch(toFetch); + final updated = Map.from(state.items); + for (final item in fetched) { + updated[item.id] = item; + } + state = state.copyWith(items: updated, isLoadingBatch: false); + } catch (_) { + state = state.copyWith(isLoadingBatch: false); + } + } + + // ── Private helpers ────────────────────────────────────────────────────── + + Future _fetchFromScratch() async { + state = state.copyWith(isLoadingIds: true, error: null); + try { + final (ids, total) = await _repo.fetchIds( + noteType: state.noteType, + tags: state.activeTags, + q: state.searchQuery, + limit: 50, + offset: 0, + ); + state = state.copyWith( + ids: ids, + items: {}, + totalIds: total, + isLoadingIds: false, + hasMore: ids.length < total, + ); + // Load counts and tags in parallel with the first batch hydration. + await Future.wait([ + hydrateNext(), + _loadCounts(), + _loadTags(), + ]); + } catch (e) { + state = state.copyWith( + isLoadingIds: false, + error: e.toString(), + ); + } + } + + Future _loadMoreIds() async { + if (!state.hasMore || state.isLoadingIds) return; + state = state.copyWith(isLoadingIds: true); + try { + final (newIds, total) = await _repo.fetchIds( + noteType: state.noteType, + tags: state.activeTags, + q: state.searchQuery, + limit: 50, + offset: state.ids.length, + ); + final combined = [...state.ids, ...newIds]; + state = state.copyWith( + ids: combined, + totalIds: total, + isLoadingIds: false, + hasMore: combined.length < total, + ); + } catch (_) { + state = state.copyWith(isLoadingIds: false); + } + } + + Future _loadCounts() async { + try { + final counts = await _repo.fetchCounts(tags: state.activeTags); + state = state.copyWith(counts: counts); + } catch (_) {} + } + + Future _loadTags() async { + try { + final tags = await _repo.fetchTags(noteType: state.noteType); + state = state.copyWith(availableTags: tags); + } catch (_) {} + } +} + +final knowledgeProvider = + NotifierProvider(KnowledgeNotifier.new); From b5d9efa3ec5a51358fcba732138f327c1006367d Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:49:18 -0400 Subject: [PATCH 08/24] feat(knowledge): add KnowledgeItemCard widget --- lib/widgets/knowledge_item_card.dart | 95 ++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 lib/widgets/knowledge_item_card.dart diff --git a/lib/widgets/knowledge_item_card.dart b/lib/widgets/knowledge_item_card.dart new file mode 100644 index 0000000..ca5b09c --- /dev/null +++ b/lib/widgets/knowledge_item_card.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/models/knowledge_item.dart'; + +class KnowledgeItemCard extends StatelessWidget { + final KnowledgeItem item; + const KnowledgeItemCard({super.key, required this.item}); + + IconData get _icon => switch (item.noteType) { + 'person' => Icons.person_outlined, + 'place' => Icons.place_outlined, + 'list' => Icons.checklist_outlined, + 'task' => Icons.task_alt_outlined, + _ => Icons.description_outlined, + }; + + Color _statusColor(BuildContext context) { + if (item.noteType != 'task') return Theme.of(context).colorScheme.primary; + return switch (item.status) { + 'done' => Colors.green, + 'in_progress' => Colors.orange, + 'cancelled' => Colors.grey, + _ => Theme.of(context).colorScheme.primary, + }; + } + + String? get _subtitle { + if (item.noteType == 'task') { + if (item.dueDate != null) return 'Due ${item.dueDate}'; + return item.status; + } + if (item.body.trim().isEmpty) return null; + final preview = item.body.trim().replaceAll('\n', ' '); + return preview.length > 120 ? '${preview.substring(0, 120)}…' : preview; + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(_icon, color: _statusColor(context)), + title: Text( + item.title.isEmpty ? '(untitled)' : item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: _subtitle != null + ? Text( + _subtitle!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ) + : null, + trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null, + onTap: () { + if (item.noteType == 'task') { + context.push('/tasks/${item.id}/edit'); + } else { + context.push('/notes/${item.id}'); + } + }, + ); + } +} + +class _TagChips extends StatelessWidget { + final List tags; + const _TagChips({required this.tags}); + + @override + Widget build(BuildContext context) { + final shown = tags.take(2).toList(); + final extra = tags.length - shown.length; + return Wrap( + spacing: 4, + children: [ + for (final t in shown) + Chip( + label: Text(t, style: const TextStyle(fontSize: 10)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + if (extra > 0) + Chip( + label: Text('+$extra', style: const TextStyle(fontSize: 10)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ); + } +} From f5ba2d25a3ebbe1f8c1719ca07bf9dc8a8dadc05 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:50:19 -0400 Subject: [PATCH 09/24] feat(knowledge): add KnowledgeScreen with tabs, search, tag filters, and infinite scroll --- lib/screens/knowledge/knowledge_screen.dart | 295 ++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 lib/screens/knowledge/knowledge_screen.dart diff --git a/lib/screens/knowledge/knowledge_screen.dart b/lib/screens/knowledge/knowledge_screen.dart new file mode 100644 index 0000000..e46d778 --- /dev/null +++ b/lib/screens/knowledge/knowledge_screen.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/knowledge_provider.dart'; +import '../../widgets/knowledge_item_card.dart'; + +// Type tab configuration: (label, noteType filter value) +const _kTabs = [ + (label: 'All', type: null), + (label: 'Notes', type: 'note'), + (label: 'People', type: 'person'), + (label: 'Places', type: 'place'), + (label: 'Lists', type: 'list'), + (label: 'Tasks', type: 'task'), +]; + +class KnowledgeScreen extends ConsumerStatefulWidget { + const KnowledgeScreen({super.key}); + + @override + ConsumerState createState() => _KnowledgeScreenState(); +} + +class _KnowledgeScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final ScrollController _scrollController; + bool _searchActive = false; + final _searchController = TextEditingController(); + var _debounce = DateTime.now(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _kTabs.length, vsync: this); + _scrollController = ScrollController(); + _scrollController.addListener(_onScroll); + // Trigger initial load + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(knowledgeProvider.notifier).refresh(); + }); + _tabController.addListener(_onTabChanged); + } + + @override + void dispose() { + _tabController.dispose(); + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (!_tabController.indexIsChanging) return; + final newType = _kTabs[_tabController.index].type; + ref.read(knowledgeProvider.notifier).setTypeFilter(newType); + } + + void _onScroll() { + final pos = _scrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 300) { + ref.read(knowledgeProvider.notifier).hydrateNext(); + } + } + + void _onSearchChanged(String q) { + final now = DateTime.now(); + _debounce = now; + Future.delayed(const Duration(milliseconds: 400), () { + if (_debounce == now && mounted) { + ref.read(knowledgeProvider.notifier).setSearch(q); + } + }); + } + + String _tabLabel(int index, Map counts) { + final tab = _kTabs[index]; + if (tab.type == null) { + final total = counts.values.fold(0, (a, b) => a + b); + return total > 0 ? 'All ($total)' : 'All'; + } + final count = counts[tab.type]; + return count != null && count > 0 ? '${tab.label} ($count)' : tab.label; + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(knowledgeProvider); + + return Scaffold( + appBar: AppBar( + title: _searchActive + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search knowledge…', + border: InputBorder.none, + ), + onChanged: _onSearchChanged, + ) + : const Text('Knowledge'), + actions: [ + IconButton( + icon: Icon(_searchActive ? Icons.close : Icons.search), + onPressed: () { + setState(() => _searchActive = !_searchActive); + if (!_searchActive) { + _searchController.clear(); + ref.read(knowledgeProvider.notifier).setSearch(null); + } + }, + ), + ], + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: List.generate( + _kTabs.length, + (i) => Tab(text: _tabLabel(i, state.counts)), + ), + ), + ), + body: Column( + children: [ + // Tag filter chips + if (state.availableTags.isNotEmpty) + SizedBox( + height: 48, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: state.availableTags.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (_, i) { + final tag = state.availableTags[i]; + final active = state.activeTags.contains(tag); + return FilterChip( + label: Text(tag), + selected: active, + onSelected: (_) => + ref.read(knowledgeProvider.notifier).toggleTag(tag), + visualDensity: VisualDensity.compact, + ); + }, + ), + ), + // Main list + Expanded( + child: _buildList(state), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _onFabTapped(context), + tooltip: 'New', + child: const Icon(Icons.edit_outlined), + ), + ); + } + + Widget _buildList(KnowledgeState state) { + if (state.isLoadingIds && state.ids.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (state.error != null && state.ids.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Failed to load', + style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 8), + TextButton( + onPressed: () => + ref.read(knowledgeProvider.notifier).refresh(), + child: const Text('Retry'), + ), + ], + ), + ); + } + final items = state.orderedItems; + if (items.isEmpty && !state.isLoadingIds && !state.isLoadingBatch) { + return Center( + child: Text( + 'No ${_kTabs[_tabController.index].label.toLowerCase()} yet.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } + return RefreshIndicator( + onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), + child: ListView.separated( + controller: _scrollController, + itemCount: items.length + (state.isLoadingBatch ? 1 : 0), + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + if (i >= items.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + return KnowledgeItemCard(item: items[i]); + }, + ), + ); + } + + void _onFabTapped(BuildContext context) { + final currentType = _kTabs[_tabController.index].type; + if (currentType == 'task') { + context.push('/tasks/new'); + return; + } + _showTypePicker(context); + } + + void _showTypePicker(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TypePickerRow( + icon: Icons.description_outlined, + label: 'Note', + description: 'General note or document', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'note'}); + }, + ), + _TypePickerRow( + icon: Icons.person_outlined, + label: 'Person', + description: 'Contact, colleague, or reference person', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'person'}); + }, + ), + _TypePickerRow( + icon: Icons.place_outlined, + label: 'Place', + description: 'Location, venue, or place of interest', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'place'}); + }, + ), + _TypePickerRow( + icon: Icons.checklist_outlined, + label: 'List', + description: 'Checklist or structured list', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'list'}); + }, + ), + ], + ), + ), + ); + } +} + +class _TypePickerRow extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final VoidCallback onTap; + + const _TypePickerRow({ + required this.icon, + required this.label, + required this.description, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Text(description), + onTap: onTap, + ); + } +} From a50193dbc0b5afc22eb8e2599ccbea34f3fa4774 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:52:10 -0400 Subject: [PATCH 10/24] feat(knowledge): update Project model, ProjectsApi sort, and NoteEditScreen noteType support --- lib/data/api/projects_api.dart | 8 +++++- lib/data/models/project.dart | 4 +++ .../repositories/projects_repository.dart | 7 +++++- lib/screens/notes/note_edit_screen.dart | 25 +++++++++++++++++-- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/data/api/projects_api.dart b/lib/data/api/projects_api.dart index c28dbd4..41edf8a 100644 --- a/lib/data/api/projects_api.dart +++ b/lib/data/api/projects_api.dart @@ -11,7 +11,11 @@ class ProjectsApi { try { final response = await _dio.get( '/api/projects', - queryParameters: status != null ? {'status': status} : null, + queryParameters: { + 'sort': 'updated_at', + 'order': 'desc', + if (status != null) 'status': status, + }, ); final data = response.data as Map; final list = data['projects'] as List; @@ -37,10 +41,12 @@ class ProjectsApi { String? description, String? goal, String? color, + String status = 'active', }) async { try { final response = await _dio.post('/api/projects', data: { 'title': title, + 'status': status, if (description != null && description.isNotEmpty) 'description': description, if (goal != null && goal.isNotEmpty) 'goal': goal, diff --git a/lib/data/models/project.dart b/lib/data/models/project.dart index e66f002..5408c29 100644 --- a/lib/data/models/project.dart +++ b/lib/data/models/project.dart @@ -5,6 +5,7 @@ class Project { final String? goal; final String status; // active | completed | archived final String? color; + final String? autoSummary; final DateTime createdAt; final DateTime updatedAt; @@ -15,6 +16,7 @@ class Project { this.goal, required this.status, this.color, + this.autoSummary, required this.createdAt, required this.updatedAt, }); @@ -26,6 +28,7 @@ class Project { goal: json['goal'] as String?, status: json['status'] as String? ?? 'active', color: json['color'] as String?, + autoSummary: json['auto_summary'] as String?, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String), ); @@ -36,5 +39,6 @@ class Project { 'goal': goal, 'status': status, 'color': color, + 'auto_summary': autoSummary, }; } diff --git a/lib/data/repositories/projects_repository.dart b/lib/data/repositories/projects_repository.dart index a9a0cdd..24f52c2 100644 --- a/lib/data/repositories/projects_repository.dart +++ b/lib/data/repositories/projects_repository.dart @@ -12,9 +12,14 @@ class ProjectsRepository { String? description, String? goal, String? color, + String status = 'active', }) => _api.create( - title: title, description: description, goal: goal, color: color); + title: title, + description: description, + goal: goal, + color: color, + status: status); Future update(int id, Map fields) => _api.update(id, fields); Future delete(int id) => _api.delete(id); diff --git a/lib/screens/notes/note_edit_screen.dart b/lib/screens/notes/note_edit_screen.dart index 0e9eefc..e02477d 100644 --- a/lib/screens/notes/note_edit_screen.dart +++ b/lib/screens/notes/note_edit_screen.dart @@ -11,7 +11,8 @@ import '../../widgets/project_selector.dart'; class NoteEditScreen extends ConsumerStatefulWidget { final int? noteId; - const NoteEditScreen({super.key, this.noteId}); + final String? noteType; // passed when creating a typed note from KnowledgeScreen + const NoteEditScreen({super.key, this.noteId, this.noteType}); @override ConsumerState createState() => _NoteEditScreenState(); @@ -25,12 +26,14 @@ class _NoteEditScreenState extends ConsumerState { int? _projectId; bool _preview = false; bool _saving = false; + late String _noteType; late final Future _initFuture; @override void initState() { super.initState(); + _noteType = widget.noteType ?? 'note'; _initFuture = widget.noteId != null ? _loadExisting() : Future.value(); } @@ -50,6 +53,7 @@ class _NoteEditScreenState extends ConsumerState { _contentController.text = note.body; _tags = List.from(note.tags); _projectId = note.projectId; + _noteType = note.noteType; } void _addTag(String raw) { @@ -108,6 +112,7 @@ class _NoteEditScreenState extends ConsumerState { body, tags: _tags, projectId: _projectId, + noteType: _noteType, ); if (mounted) context.pop(); } else { @@ -118,6 +123,7 @@ class _NoteEditScreenState extends ConsumerState { tags: _tags, projectId: _projectId, clearProject: _projectId == null, + noteType: _noteType, ); if (mounted) context.pop(); } @@ -138,7 +144,22 @@ class _NoteEditScreenState extends ConsumerState { builder: (context, snapshot) { return Scaffold( appBar: AppBar( - title: Text(widget.noteId == null ? 'New Note' : 'Edit Note'), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.noteId == null ? 'New Note' : 'Edit Note'), + if (_noteType != 'note') + Chip( + label: Text( + _noteType, + style: const TextStyle(fontSize: 11), + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), actions: [ if (widget.noteId != null) IconButton( From e39d31fe43d98150344283227f035cb85e20c163 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:56:18 -0400 Subject: [PATCH 11/24] feat(knowledge): add ProjectsScreen and ProjectEditScreen, sort projects by updated_at --- lib/data/api/projects_api.dart | 10 +- .../repositories/projects_repository.dart | 7 +- lib/providers/projects_provider.dart | 4 +- lib/screens/projects/project_edit_screen.dart | 164 ++++++++++++++++++ lib/screens/projects/projects_screen.dart | 89 ++++++++++ 5 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 lib/screens/projects/project_edit_screen.dart create mode 100644 lib/screens/projects/projects_screen.dart diff --git a/lib/data/api/projects_api.dart b/lib/data/api/projects_api.dart index 41edf8a..548b23f 100644 --- a/lib/data/api/projects_api.dart +++ b/lib/data/api/projects_api.dart @@ -7,13 +7,17 @@ class ProjectsApi { final Dio _dio; const ProjectsApi(this._dio); - Future> getAll({String? status}) async { + Future> getAll({ + String? status, + String sort = 'updated_at', + String order = 'desc', + }) async { try { final response = await _dio.get( '/api/projects', queryParameters: { - 'sort': 'updated_at', - 'order': 'desc', + 'sort': sort, + 'order': order, if (status != null) 'status': status, }, ); diff --git a/lib/data/repositories/projects_repository.dart b/lib/data/repositories/projects_repository.dart index 24f52c2..ca93578 100644 --- a/lib/data/repositories/projects_repository.dart +++ b/lib/data/repositories/projects_repository.dart @@ -5,7 +5,12 @@ class ProjectsRepository { final ProjectsApi _api; const ProjectsRepository(this._api); - Future> getAll({String? status}) => _api.getAll(status: status); + Future> getAll({ + String? status, + String sort = 'updated_at', + String order = 'desc', + }) => + _api.getAll(status: status, sort: sort, order: order); Future getOne(int id) => _api.getOne(id); Future create({ required String title, diff --git a/lib/providers/projects_provider.dart b/lib/providers/projects_provider.dart index 5c9d940..d3981d5 100644 --- a/lib/providers/projects_provider.dart +++ b/lib/providers/projects_provider.dart @@ -10,7 +10,9 @@ final projectsProvider = class ProjectsNotifier extends AsyncNotifier> { @override Future> build() async { - return ref.watch(projectsRepositoryProvider).getAll(); + return ref + .watch(projectsRepositoryProvider) + .getAll(sort: 'updated_at', order: 'desc'); } Future create({ diff --git a/lib/screens/projects/project_edit_screen.dart b/lib/screens/projects/project_edit_screen.dart new file mode 100644 index 0000000..ca5f3e3 --- /dev/null +++ b/lib/screens/projects/project_edit_screen.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/exceptions.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectEditScreen extends ConsumerStatefulWidget { + final int? projectId; + const ProjectEditScreen({super.key, this.projectId}); + + @override + ConsumerState createState() => _ProjectEditScreenState(); +} + +class _ProjectEditScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final _goalController = TextEditingController(); + String _status = 'active'; + bool _saving = false; + late final Future _initFuture; + + @override + void initState() { + super.initState(); + _initFuture = + widget.projectId != null ? _loadExisting() : Future.value(); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + _goalController.dispose(); + super.dispose(); + } + + Future _loadExisting() async { + final projects = ref.read(projectsProvider).value ?? []; + final project = + projects.where((p) => p.id == widget.projectId).firstOrNull; + if (project != null) { + _titleController.text = project.title; + _descController.text = project.description ?? ''; + _goalController.text = project.goal ?? ''; + _status = project.status; + } + } + + Future _save() async { + final title = _titleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + if (widget.projectId == null) { + await ref.read(projectsProvider.notifier).create( + title: title, + description: _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + goal: _goalController.text.trim().isEmpty + ? null + : _goalController.text.trim(), + ); + } else { + await ref.read(projectsProvider.notifier).updateProject( + widget.projectId!, + { + 'title': title, + 'description': _descController.text.trim(), + 'goal': _goalController.text.trim(), + 'status': _status, + }, + ); + } + if (mounted) context.pop(); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _initFuture, + builder: (context, snapshot) => Scaffold( + appBar: AppBar( + title: Text( + widget.projectId == null ? 'New Project' : 'Edit Project'), + actions: [ + IconButton( + icon: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + onPressed: _saving ? null : _save, + ), + ], + ), + body: snapshot.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title *', border: OutlineInputBorder()), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder()), + maxLines: 3, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _goalController, + decoration: const InputDecoration( + labelText: 'Goal', border: OutlineInputBorder()), + maxLines: 2, + textInputAction: TextInputAction.done, + ), + if (widget.projectId != null) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _status, + decoration: const InputDecoration( + labelText: 'Status', + border: OutlineInputBorder()), + items: const [ + DropdownMenuItem( + value: 'active', child: Text('Active')), + DropdownMenuItem( + value: 'completed', child: Text('Completed')), + DropdownMenuItem( + value: 'archived', child: Text('Archived')), + ], + onChanged: (v) => setState(() => _status = v!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/screens/projects/projects_screen.dart b/lib/screens/projects/projects_screen.dart new file mode 100644 index 0000000..a468ff0 --- /dev/null +++ b/lib/screens/projects/projects_screen.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/models/project.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectsScreen extends ConsumerWidget { + const ProjectsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = ref.watch(projectsProvider); + return Scaffold( + appBar: AppBar(title: const Text('Projects')), + body: projectsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (projects) => projects.isEmpty + ? const Center(child: Text('No projects yet.')) + : RefreshIndicator( + onRefresh: () async => ref.invalidate(projectsProvider), + child: ListView.separated( + itemCount: projects.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) => _ProjectCard(project: projects[i]), + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.push('/projects/new'), + tooltip: 'New project', + child: const Icon(Icons.add), + ), + ); + } +} + +class _ProjectCard extends StatelessWidget { + final Project project; + const _ProjectCard({required this.project}); + + Color _statusColor(BuildContext context) => switch (project.status) { + 'completed' => Colors.blue, + 'archived' => Colors.grey, + _ => Theme.of(context).colorScheme.primary, + }; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(project.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (project.description?.isNotEmpty == true) + Text( + project.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (project.goal?.isNotEmpty == true) + Text( + project.goal!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + ], + ), + trailing: Chip( + label: Text( + project.status, + style: TextStyle( + fontSize: 11, + color: _statusColor(context), + ), + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onTap: () => context.push('/projects/${project.id}/tasks'), + ); + } +} From 2ab24a99a8f849d35746125ace8104f55cf0014a Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sat, 4 Apr 2026 23:56:40 -0400 Subject: [PATCH 12/24] feat(knowledge): add edit button to ProjectTasksScreen app bar --- lib/screens/library/project_tasks_screen.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/screens/library/project_tasks_screen.dart b/lib/screens/library/project_tasks_screen.dart index b21bbf5..9724df3 100644 --- a/lib/screens/library/project_tasks_screen.dart +++ b/lib/screens/library/project_tasks_screen.dart @@ -88,6 +88,14 @@ class _ProjectTasksScreenState extends ConsumerState { ), ], ), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit project', + onPressed: () => + context.push('/projects/${widget.projectId}/edit'), + ), + ], ), body: tasksAsync.when( loading: () => const Center(child: CircularProgressIndicator()), From d97f7b0ebd88fdfddfe20e1af7550ff5e2e232b6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 00:03:23 -0400 Subject: [PATCH 13/24] feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature --- .../plans/2026-04-04-knowledge-view.md | 2344 +++++++++++++++++ lib/app.dart | 56 +- lib/screens/library/library_screen.dart | 294 --- lib/widgets/library_item_card.dart | 272 -- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 64 + .../flutter/generated_plugin_registrant.cc | 9 + windows/flutter/generated_plugins.cmake | 3 + 10 files changed, 2477 insertions(+), 579 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-04-knowledge-view.md delete mode 100644 lib/screens/library/library_screen.dart delete mode 100644 lib/widgets/library_item_card.dart diff --git a/docs/superpowers/plans/2026-04-04-knowledge-view.md b/docs/superpowers/plans/2026-04-04-knowledge-view.md new file mode 100644 index 0000000..ac2fe0c --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-knowledge-view.md @@ -0,0 +1,2344 @@ +# Knowledge View 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 Library tab with a typed Knowledge view, add a Projects tab, and wire a two-tier paginated feed (IDs + batch hydration) backed by `/api/knowledge`. + +**Architecture:** Four-tab shell (Briefing / Knowledge / Chat / Projects). Knowledge screen fetches up to 50 IDs per page then hydrates 12 items at a time via batch endpoint. Type tabs filter server-side. Projects screen lists all projects sorted by `updated_at desc` with a dedicated create/edit screen. All state managed with Riverpod StateNotifier / AsyncNotifier following existing app patterns. + +**Tech Stack:** Flutter 3, Riverpod 3, GoRouter 17, Dio 5. No new pubspec dependencies. + +--- + +## Manifest Pre-Check (do this before Task 1) + +Open `android/app/src/main/AndroidManifest.xml` and confirm all three of the following are present. **If any are missing, add them before proceeding.** + +- `` — already present ✓ +- `` — already present ✓ +- `android:usesCleartextTraffic="true"` on the `` tag — already present ✓ + +No manifest changes required for this feature. + +--- + +## File Map + +| Action | File | +|---|---| +| Modify | `lib/core/constants.dart` | +| Modify | `lib/data/models/note.dart` | +| Modify | `lib/data/api/notes_api.dart` | +| Modify | `lib/data/repositories/notes_repository.dart` | +| Modify | `lib/providers/notes_provider.dart` | +| Modify | `lib/screens/notes/note_edit_screen.dart` | +| Create | `lib/data/models/knowledge_item.dart` | +| Create | `lib/data/api/knowledge_api.dart` | +| Create | `lib/data/repositories/knowledge_repository.dart` | +| Modify | `lib/providers/api_client_provider.dart` | +| Create | `lib/providers/knowledge_provider.dart` | +| Create | `lib/widgets/knowledge_item_card.dart` | +| Create | `lib/screens/knowledge/knowledge_screen.dart` | +| Modify | `lib/data/api/projects_api.dart` | +| Modify | `lib/data/repositories/projects_repository.dart` | +| Modify | `lib/providers/projects_provider.dart` | +| Create | `lib/screens/projects/projects_screen.dart` | +| Create | `lib/screens/projects/project_edit_screen.dart` | +| Modify | `lib/screens/library/project_tasks_screen.dart` | +| Modify | `lib/app.dart` | +| Delete | `lib/screens/library/library_screen.dart` | +| Delete | `lib/widgets/library_item_card.dart` | + +--- + +## Task 1: Route Constants + +**Files:** +- Modify: `lib/core/constants.dart` + +- [ ] **Step 1: Update constants** + +Replace the contents of `lib/core/constants.dart` 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'; +} +``` + +Changes: `library` removed, `knowledge` added, `projectEdit` added. + +- [ ] **Step 2: Verify** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter analyze lib/core/constants.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/core/constants.dart +git commit -m "feat(knowledge): add knowledge + projectEdit route constants" +``` + +--- + +## Task 2: Note Model — Add noteType + +**Files:** +- Modify: `lib/data/models/note.dart` +- Modify: `lib/data/api/notes_api.dart` +- Modify: `lib/data/repositories/notes_repository.dart` +- Modify: `lib/providers/notes_provider.dart` +- Modify: `test/widget_test.dart` + +- [ ] **Step 1: Write a failing test** + +Replace `test/widget_test.dart` with: + +```dart +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Note.fromJson', () { + test('parses noteType when present', () { + final json = { + 'id': 1, + 'title': 'Alice', + 'body': '', + 'tags': [], + 'note_type': 'person', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + // Note class is not imported yet — this will fail to compile + // until Task 2 Step 2 is complete. + }); + }); +} +``` + +Run to confirm it fails: + +```bash +flutter test test/widget_test.dart +``` + +Expected: compilation error (Note not imported) — confirms test is wired. + +- [ ] **Step 2: Update `lib/data/models/note.dart`** + +```dart +class Note { + final int id; + final String title; + final String body; + final List tags; + final String noteType; + final int? projectId; + final int? milestoneId; + final DateTime createdAt; + final DateTime updatedAt; + + const Note({ + required this.id, + required this.title, + required this.body, + required this.tags, + this.noteType = 'note', + this.projectId, + this.milestoneId, + required this.createdAt, + required this.updatedAt, + }); + + factory Note.fromJson(Map json) => Note( + id: json['id'] as int, + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + tags: (json['tags'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + noteType: json['note_type'] as String? ?? 'note', + projectId: json['project_id'] as int?, + milestoneId: json['milestone_id'] as int?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + + Map toJson() => { + 'title': title, + 'body': body, + 'tags': tags, + 'note_type': noteType, + 'project_id': projectId, + 'milestone_id': milestoneId, + }; + + Note copyWith({ + String? title, + String? body, + List? tags, + String? noteType, + Object? projectId = _undefined, + Object? milestoneId = _undefined, + }) => + Note( + id: id, + title: title ?? this.title, + body: body ?? this.body, + tags: tags ?? this.tags, + noteType: noteType ?? this.noteType, + projectId: identical(projectId, _undefined) + ? this.projectId + : projectId as int?, + milestoneId: identical(milestoneId, _undefined) + ? this.milestoneId + : milestoneId as int?, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + static const _undefined = Object(); +} +``` + +- [ ] **Step 3: Write the real test and verify it passes** + +Replace `test/widget_test.dart`: + +```dart +import 'package:fabled_app/data/models/note.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Note.fromJson', () { + test('parses noteType when present', () { + final json = { + 'id': 1, + 'title': 'Alice', + 'body': '', + 'tags': [], + 'note_type': 'person', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('person')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 2, + 'title': 'My note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('note')); + }); + }); +} +``` + +```bash +flutter test test/widget_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 4: Update `lib/data/api/notes_api.dart`** + +Add `noteType` parameter to `create` and `update`: + +```dart +import 'package:dio/dio.dart'; + +import '../models/note.dart'; +import 'api_client.dart'; + +class NotesApi { + final Dio _dio; + const NotesApi(this._dio); + + Future> getAll() async { + try { + final response = await _dio.get('/api/notes'); + final data = response.data as Map; + final list = data['notes'] as List; + return list.map((e) => Note.fromJson(e as Map)).toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future getOne(int id) async { + try { + final response = await _dio.get('/api/notes/$id'); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + String noteType = 'note', + }) async { + try { + final response = await _dio.post('/api/notes', data: { + 'title': title, + 'body': body, + 'tags': tags, + 'note_type': noteType, + if (projectId != null) 'project_id': projectId, + }); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future update( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + String noteType = 'note', + }) async { + try { + final response = await _dio.put('/api/notes/$id', data: { + 'title': title, + 'body': body, + 'tags': tags, + 'note_type': noteType, + if (clearProject) 'project_id': null + else if (projectId != null) 'project_id': projectId, + }); + return Note.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + Future delete(int id) async { + try { + await _dio.delete('/api/notes/$id'); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} +``` + +- [ ] **Step 5: Update `lib/data/repositories/notes_repository.dart`** + +```dart +import '../api/notes_api.dart'; +import '../models/note.dart'; + +class NotesRepository { + final NotesApi _api; + const NotesRepository(this._api); + + Future> getAll() => _api.getAll(); + Future getOne(int id) => _api.getOne(id); + + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + String noteType = 'note', + }) => + _api.create(title, body, + tags: tags, projectId: projectId, noteType: noteType); + + Future update( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + String noteType = 'note', + }) => + _api.update(id, title, body, + tags: tags, + projectId: projectId, + clearProject: clearProject, + noteType: noteType); + + Future delete(int id) => _api.delete(id); +} +``` + +- [ ] **Step 6: Update `lib/providers/notes_provider.dart`** + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/note.dart'; +import 'api_client_provider.dart'; + +final notesProvider = + AsyncNotifierProvider>(NotesNotifier.new); + +class NotesNotifier extends AsyncNotifier> { + @override + Future> build() async { + return ref.watch(notesRepositoryProvider).getAll(); + } + + Future create( + String title, + String body, { + List tags = const [], + int? projectId, + String noteType = 'note', + }) async { + final note = await ref.read(notesRepositoryProvider).create( + title, body, + tags: tags, + projectId: projectId, + noteType: noteType, + ); + state = AsyncData([...state.value ?? [], note]); + return note; + } + + Future updateNote( + int id, + String title, + String body, { + List tags = const [], + int? projectId, + bool clearProject = false, + String noteType = 'note', + }) async { + final updated = await ref.read(notesRepositoryProvider).update( + id, title, body, + tags: tags, + projectId: projectId, + clearProject: clearProject, + noteType: noteType, + ); + state = AsyncData([ + for (final n in state.value ?? []) + if (n.id == id) updated else n, + ]); + return updated; + } + + Future delete(int id) async { + await ref.read(notesRepositoryProvider).delete(id); + state = AsyncData([ + for (final n in state.value ?? []) + if (n.id != id) n, + ]); + } +} + +final noteDetailProvider = + FutureProvider.family((ref, id) async { + return ref.watch(notesRepositoryProvider).getOne(id); +}); +``` + +- [ ] **Step 7: Analyze** + +```bash +flutter analyze lib/data/models/note.dart lib/data/api/notes_api.dart lib/data/repositories/notes_repository.dart lib/providers/notes_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 8: Commit** + +```bash +git add lib/data/models/note.dart lib/data/api/notes_api.dart lib/data/repositories/notes_repository.dart lib/providers/notes_provider.dart test/widget_test.dart +git commit -m "feat(knowledge): add noteType field to Note model and thread through API/repo/provider" +``` + +--- + +## Task 3: KnowledgeItem Model + +**Files:** +- Create: `lib/data/models/knowledge_item.dart` + +- [ ] **Step 1: Create the model** + +Create `lib/data/models/knowledge_item.dart`: + +```dart +/// Unified model for all knowledge types returned by /api/knowledge. +/// noteType is one of: 'note' | 'person' | 'place' | 'list' | 'task' +class KnowledgeItem { + final int id; + final String noteType; + final String title; + final String body; + final List tags; + final int? projectId; + final int? milestoneId; + final int? parentId; + // Task-only fields — null for non-task types + final String? status; + final String? priority; + final String? dueDate; + final DateTime createdAt; + final DateTime updatedAt; + + const KnowledgeItem({ + required this.id, + required this.noteType, + required this.title, + required this.body, + required this.tags, + this.projectId, + this.milestoneId, + this.parentId, + this.status, + this.priority, + this.dueDate, + required this.createdAt, + required this.updatedAt, + }); + + factory KnowledgeItem.fromJson(Map json) => KnowledgeItem( + id: json['id'] as int, + noteType: json['note_type'] as String? ?? 'note', + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + tags: (json['tags'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + projectId: json['project_id'] as int?, + milestoneId: json['milestone_id'] as int?, + parentId: json['parent_id'] as int?, + status: json['status'] as String?, + priority: json['priority'] as String?, + dueDate: json['due_date'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); +} +``` + +- [ ] **Step 2: Add a unit test to `test/widget_test.dart`** + +Append this group to the existing `main()` in `test/widget_test.dart`: + +```dart +// Add this import at the top of the file: +// import 'package:fabled_app/data/models/knowledge_item.dart'; + + group('KnowledgeItem.fromJson', () { + test('parses a task item with task fields', () { + final json = { + 'id': 10, + 'note_type': 'task', + 'title': 'Do the thing', + 'body': '', + 'tags': [], + 'status': 'todo', + 'priority': 'high', + 'due_date': '2025-01-01', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('task')); + expect(item.status, equals('todo')); + expect(item.priority, equals('high')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 11, + 'title': 'A note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('note')); + expect(item.status, isNull); + }); + }); +``` + +Full updated `test/widget_test.dart`: + +```dart +import 'package:fabled_app/data/models/knowledge_item.dart'; +import 'package:fabled_app/data/models/note.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Note.fromJson', () { + test('parses noteType when present', () { + final json = { + 'id': 1, + 'title': 'Alice', + 'body': '', + 'tags': [], + 'note_type': 'person', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('person')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 2, + 'title': 'My note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final note = Note.fromJson(json); + expect(note.noteType, equals('note')); + }); + }); + + group('KnowledgeItem.fromJson', () { + test('parses a task item with task fields', () { + final json = { + 'id': 10, + 'note_type': 'task', + 'title': 'Do the thing', + 'body': '', + 'tags': [], + 'status': 'todo', + 'priority': 'high', + 'due_date': '2025-01-01', + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('task')); + expect(item.status, equals('todo')); + expect(item.priority, equals('high')); + }); + + test('defaults noteType to note when absent', () { + final json = { + 'id': 11, + 'title': 'A note', + 'body': '', + 'tags': [], + 'created_at': '2024-01-01T00:00:00', + 'updated_at': '2024-01-01T00:00:00', + }; + final item = KnowledgeItem.fromJson(json); + expect(item.noteType, equals('note')); + expect(item.status, isNull); + }); + }); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +flutter test test/widget_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 4: Commit** + +```bash +git add lib/data/models/knowledge_item.dart test/widget_test.dart +git commit -m "feat(knowledge): add KnowledgeItem model with unit tests" +``` + +--- + +## Task 4: KnowledgeApi + KnowledgeRepository + Provider Wiring + +**Files:** +- Create: `lib/data/api/knowledge_api.dart` +- Create: `lib/data/repositories/knowledge_repository.dart` +- Modify: `lib/providers/api_client_provider.dart` + +- [ ] **Step 1: Create `lib/data/api/knowledge_api.dart`** + +```dart +import 'package:dio/dio.dart'; + +import '../models/knowledge_item.dart'; +import 'api_client.dart'; + +class KnowledgeApi { + final Dio _dio; + const KnowledgeApi(this._dio); + + /// Fetch a page of IDs matching the given filters. + /// Returns (ids, total). + Future<(List, int)> fetchIds({ + String? noteType, + List tags = const [], + String sort = 'modified', + String? q, + int limit = 50, + int offset = 0, + }) async { + try { + final params = { + 'limit': limit, + 'offset': offset, + 'sort': sort, + if (noteType != null) 'type': noteType, + if (tags.isNotEmpty) 'tags': tags.join(','), + if (q != null && q.isNotEmpty) 'q': q, + }; + final response = await _dio.get( + '/api/knowledge/ids', + queryParameters: params, + ); + final data = response.data as Map; + final ids = + (data['ids'] as List).map((e) => e as int).toList(); + final total = data['total'] as int; + return (ids, total); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// Batch-hydrate up to 100 IDs into full KnowledgeItems. + Future> fetchBatch(List ids) async { + if (ids.isEmpty) return []; + try { + final response = await _dio.get( + '/api/knowledge/batch', + queryParameters: {'ids': ids.join(',')}, + ); + final data = response.data as Map; + return (data['items'] as List) + .map((e) => KnowledgeItem.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// Per-type counts for tab labels. + Future> fetchCounts({List tags = const []}) async { + try { + final response = await _dio.get( + '/api/knowledge/counts', + queryParameters: { + if (tags.isNotEmpty) 'tags': tags.join(','), + }, + ); + return (response.data as Map) + .map((k, v) => MapEntry(k, (v as num).toInt())); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// Tags available for the given type filter. + Future> fetchTags({String? noteType}) async { + try { + final response = await _dio.get( + '/api/knowledge/tags', + queryParameters: { + if (noteType != null) 'type': noteType, + }, + ); + return (response.data['tags'] as List) + .map((e) => e as String) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} +``` + +- [ ] **Step 2: Create `lib/data/repositories/knowledge_repository.dart`** + +```dart +import '../api/knowledge_api.dart'; +import '../models/knowledge_item.dart'; + +class KnowledgeRepository { + final KnowledgeApi _api; + const KnowledgeRepository(this._api); + + Future<(List, int)> fetchIds({ + String? noteType, + List tags = const [], + String sort = 'modified', + String? q, + int limit = 50, + int offset = 0, + }) => + _api.fetchIds( + noteType: noteType, + tags: tags, + sort: sort, + q: q, + limit: limit, + offset: offset, + ); + + Future> fetchBatch(List ids) => + _api.fetchBatch(ids); + + Future> fetchCounts({List tags = const []}) => + _api.fetchCounts(tags: tags); + + Future> fetchTags({String? noteType}) => + _api.fetchTags(noteType: noteType); +} +``` + +- [ ] **Step 3: Add providers to `lib/providers/api_client_provider.dart`** + +Add these imports at the top (after the existing imports): + +```dart +import '../data/api/knowledge_api.dart'; +import '../data/repositories/knowledge_repository.dart'; +``` + +Add these provider declarations at the bottom of the file (before the last closing brace if any, otherwise just append): + +```dart +final knowledgeApiProvider = Provider((ref) { + return KnowledgeApi(ref.watch(dioProvider)); +}); + +final knowledgeRepositoryProvider = Provider((ref) { + return KnowledgeRepository(ref.watch(knowledgeApiProvider)); +}); +``` + +- [ ] **Step 4: Analyze** + +```bash +flutter analyze lib/data/api/knowledge_api.dart lib/data/repositories/knowledge_repository.dart lib/providers/api_client_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 5: Commit** + +```bash +git add lib/data/api/knowledge_api.dart lib/data/repositories/knowledge_repository.dart lib/providers/api_client_provider.dart +git commit -m "feat(knowledge): add KnowledgeApi, KnowledgeRepository, and provider wiring" +``` + +--- + +## Task 5: KnowledgeNotifier + +**Files:** +- Create: `lib/providers/knowledge_provider.dart` + +- [ ] **Step 1: Create `lib/providers/knowledge_provider.dart`** + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../data/models/knowledge_item.dart'; +import 'api_client_provider.dart'; + +/// Immutable state for the Knowledge screen's two-tier paginated feed. +class KnowledgeState { + final List ids; + final Map items; + final int totalIds; + final bool isLoadingIds; + final bool isLoadingBatch; + final bool hasMore; + final String? noteType; // null = All + final List activeTags; + final String? searchQuery; + final Map counts; + final List availableTags; + final String? error; + + const KnowledgeState({ + this.ids = const [], + this.items = const {}, + this.totalIds = 0, + this.isLoadingIds = false, + this.isLoadingBatch = false, + this.hasMore = false, + this.noteType, + this.activeTags = const [], + this.searchQuery, + this.counts = const {}, + this.availableTags = const [], + this.error, + }); + + /// Items in server-defined ID order, only those already hydrated. + List get orderedItems => + ids.where(items.containsKey).map((id) => items[id]!).toList(); + + /// IDs that have been fetched but not yet hydrated. + List get unhydratedIds => + ids.where((id) => !items.containsKey(id)).toList(); + + KnowledgeState copyWith({ + List? ids, + Map? items, + int? totalIds, + bool? isLoadingIds, + bool? isLoadingBatch, + bool? hasMore, + Object? noteType = _keep, + List? activeTags, + Object? searchQuery = _keep, + Map? counts, + List? availableTags, + Object? error = _keep, + }) => + KnowledgeState( + ids: ids ?? this.ids, + items: items ?? this.items, + totalIds: totalIds ?? this.totalIds, + isLoadingIds: isLoadingIds ?? this.isLoadingIds, + isLoadingBatch: isLoadingBatch ?? this.isLoadingBatch, + hasMore: hasMore ?? this.hasMore, + noteType: identical(noteType, _keep) ? this.noteType : noteType as String?, + activeTags: activeTags ?? this.activeTags, + searchQuery: identical(searchQuery, _keep) ? this.searchQuery : searchQuery as String?, + counts: counts ?? this.counts, + availableTags: availableTags ?? this.availableTags, + error: identical(error, _keep) ? this.error : error as String?, + ); + + static const _keep = Object(); +} + +class KnowledgeNotifier extends StateNotifier { + KnowledgeNotifier(this._ref) : super(const KnowledgeState()); + + final Ref _ref; + + KnowledgeRepository get _repo => _ref.read(knowledgeRepositoryProvider); + + // ── Filter setters — each resets and re-fetches ────────────────────────── + + Future setTypeFilter(String? noteType) async { + state = KnowledgeState(noteType: noteType, activeTags: state.activeTags); + await _fetchFromScratch(); + } + + Future toggleTag(String tag) async { + final tags = state.activeTags.contains(tag) + ? state.activeTags.where((t) => t != tag).toList() + : [...state.activeTags, tag]; + state = KnowledgeState( + noteType: state.noteType, + activeTags: tags, + searchQuery: state.searchQuery, + ); + await _fetchFromScratch(); + } + + Future setSearch(String? q) async { + final query = (q?.trim().isEmpty ?? true) ? null : q?.trim(); + state = KnowledgeState( + noteType: state.noteType, + activeTags: state.activeTags, + searchQuery: query, + ); + await _fetchFromScratch(); + } + + Future refresh() async { + state = KnowledgeState( + noteType: state.noteType, + activeTags: state.activeTags, + searchQuery: state.searchQuery, + ); + await _fetchFromScratch(); + } + + // ── Scroll-triggered loaders ───────────────────────────────────────────── + + /// Hydrate the next 12 un-hydrated IDs. Call when approaching scroll end. + Future hydrateNext() async { + if (state.isLoadingBatch) return; + final toFetch = state.unhydratedIds.take(12).toList(); + if (toFetch.isEmpty) { + // All fetched IDs are hydrated — try loading more IDs. + if (state.hasMore && !state.isLoadingIds) await _loadMoreIds(); + return; + } + state = state.copyWith(isLoadingBatch: true); + try { + final fetched = await _repo.fetchBatch(toFetch); + final updated = Map.from(state.items); + for (final item in fetched) { + updated[item.id] = item; + } + state = state.copyWith(items: updated, isLoadingBatch: false); + } catch (_) { + state = state.copyWith(isLoadingBatch: false); + } + } + + // ── Private helpers ────────────────────────────────────────────────────── + + Future _fetchFromScratch() async { + state = state.copyWith(isLoadingIds: true, error: null); + try { + final (ids, total) = await _repo.fetchIds( + noteType: state.noteType, + tags: state.activeTags, + q: state.searchQuery, + limit: 50, + offset: 0, + ); + state = state.copyWith( + ids: ids, + items: {}, + totalIds: total, + isLoadingIds: false, + hasMore: ids.length < total, + ); + // Load counts and tags in parallel with the first batch hydration. + await Future.wait([ + hydrateNext(), + _loadCounts(), + _loadTags(), + ]); + } catch (e) { + state = state.copyWith( + isLoadingIds: false, + error: e.toString(), + ); + } + } + + Future _loadMoreIds() async { + if (!state.hasMore || state.isLoadingIds) return; + state = state.copyWith(isLoadingIds: true); + try { + final (newIds, total) = await _repo.fetchIds( + noteType: state.noteType, + tags: state.activeTags, + q: state.searchQuery, + limit: 50, + offset: state.ids.length, + ); + final combined = [...state.ids, ...newIds]; + state = state.copyWith( + ids: combined, + totalIds: total, + isLoadingIds: false, + hasMore: combined.length < total, + ); + } catch (_) { + state = state.copyWith(isLoadingIds: false); + } + } + + Future _loadCounts() async { + try { + final counts = await _repo.fetchCounts(tags: state.activeTags); + state = state.copyWith(counts: counts); + } catch (_) {} + } + + Future _loadTags() async { + try { + final tags = await _repo.fetchTags(noteType: state.noteType); + state = state.copyWith(availableTags: tags); + } catch (_) {} + } +} + +final knowledgeProvider = + StateNotifierProvider( + (ref) => KnowledgeNotifier(ref), +); +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/providers/knowledge_provider.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/providers/knowledge_provider.dart +git commit -m "feat(knowledge): add KnowledgeNotifier with two-tier pagination state" +``` + +--- + +## Task 6: KnowledgeItemCard Widget + +**Files:** +- Create: `lib/widgets/knowledge_item_card.dart` + +- [ ] **Step 1: Create `lib/widgets/knowledge_item_card.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../data/models/knowledge_item.dart'; + +class KnowledgeItemCard extends StatelessWidget { + final KnowledgeItem item; + const KnowledgeItemCard({super.key, required this.item}); + + IconData get _icon => switch (item.noteType) { + 'person' => Icons.person_outlined, + 'place' => Icons.place_outlined, + 'list' => Icons.checklist_outlined, + 'task' => Icons.task_alt_outlined, + _ => Icons.description_outlined, + }; + + Color _statusColor(BuildContext context) { + if (item.noteType != 'task') return Theme.of(context).colorScheme.primary; + return switch (item.status) { + 'done' => Colors.green, + 'in_progress' => Colors.orange, + 'cancelled' => Colors.grey, + _ => Theme.of(context).colorScheme.primary, + }; + } + + String? get _subtitle { + if (item.noteType == 'task') { + if (item.dueDate != null) return 'Due ${item.dueDate}'; + return item.status; + } + if (item.body.trim().isEmpty) return null; + final preview = item.body.trim().replaceAll('\n', ' '); + return preview.length > 120 ? '${preview.substring(0, 120)}…' : preview; + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(_icon, color: _statusColor(context)), + title: Text( + item.title.isEmpty ? '(untitled)' : item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: _subtitle != null + ? Text( + _subtitle!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ) + : null, + trailing: item.tags.isNotEmpty + ? _TagChips(tags: item.tags) + : null, + onTap: () { + if (item.noteType == 'task') { + context.push('/tasks/${item.id}/edit'); + } else { + context.push('/notes/${item.id}'); + } + }, + ); + } +} + +class _TagChips extends StatelessWidget { + final List tags; + const _TagChips({required this.tags}); + + @override + Widget build(BuildContext context) { + final shown = tags.take(2).toList(); + final extra = tags.length - shown.length; + return Wrap( + spacing: 4, + children: [ + for (final t in shown) + Chip( + label: Text(t, style: const TextStyle(fontSize: 10)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + if (extra > 0) + Chip( + label: Text('+$extra', style: const TextStyle(fontSize: 10)), + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ); + } +} +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/widgets/knowledge_item_card.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/widgets/knowledge_item_card.dart +git commit -m "feat(knowledge): add KnowledgeItemCard widget" +``` + +--- + +## Task 7: KnowledgeScreen + +**Files:** +- Create: `lib/screens/knowledge/knowledge_screen.dart` + +- [ ] **Step 1: Create `lib/screens/knowledge/knowledge_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/knowledge_provider.dart'; +import '../../widgets/knowledge_item_card.dart'; + +// Type tab configuration: (label, noteType filter value) +const _kTabs = [ + (label: 'All', type: null), + (label: 'Notes', type: 'note'), + (label: 'People', type: 'person'), + (label: 'Places', type: 'place'), + (label: 'Lists', type: 'list'), + (label: 'Tasks', type: 'task'), +]; + +class KnowledgeScreen extends ConsumerStatefulWidget { + const KnowledgeScreen({super.key}); + + @override + ConsumerState createState() => _KnowledgeScreenState(); +} + +class _KnowledgeScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final ScrollController _scrollController; + bool _searchActive = false; + final _searchController = TextEditingController(); + var _debounce = DateTime.now(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: _kTabs.length, vsync: this); + _scrollController = ScrollController(); + _scrollController.addListener(_onScroll); + // Trigger initial load + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(knowledgeProvider.notifier).refresh(); + }); + _tabController.addListener(_onTabChanged); + } + + @override + void dispose() { + _tabController.dispose(); + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _onTabChanged() { + if (!_tabController.indexIsChanging) return; + final newType = _kTabs[_tabController.index].type; + ref.read(knowledgeProvider.notifier).setTypeFilter(newType); + } + + void _onScroll() { + final pos = _scrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 300) { + ref.read(knowledgeProvider.notifier).hydrateNext(); + } + } + + void _onSearchChanged(String q) { + final now = DateTime.now(); + _debounce = now; + Future.delayed(const Duration(milliseconds: 400), () { + if (_debounce == now && mounted) { + ref.read(knowledgeProvider.notifier).setSearch(q); + } + }); + } + + String _tabLabel(int index, Map counts) { + final tab = _kTabs[index]; + if (tab.type == null) { + final total = counts.values.fold(0, (a, b) => a + b); + return total > 0 ? 'All ($total)' : 'All'; + } + final count = counts[tab.type]; + return count != null && count > 0 ? '${tab.label} ($count)' : tab.label; + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(knowledgeProvider); + + return Scaffold( + appBar: AppBar( + title: _searchActive + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search knowledge…', + border: InputBorder.none, + ), + onChanged: _onSearchChanged, + ) + : const Text('Knowledge'), + actions: [ + IconButton( + icon: Icon(_searchActive ? Icons.close : Icons.search), + onPressed: () { + setState(() => _searchActive = !_searchActive); + if (!_searchActive) { + _searchController.clear(); + ref.read(knowledgeProvider.notifier).setSearch(null); + } + }, + ), + ], + bottom: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + tabs: List.generate( + _kTabs.length, + (i) => Tab(text: _tabLabel(i, state.counts)), + ), + ), + ), + body: Column( + children: [ + // Tag filter chips + if (state.availableTags.isNotEmpty) + SizedBox( + height: 48, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: state.availableTags.length, + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (_, i) { + final tag = state.availableTags[i]; + final active = state.activeTags.contains(tag); + return FilterChip( + label: Text(tag), + selected: active, + onSelected: (_) => + ref.read(knowledgeProvider.notifier).toggleTag(tag), + visualDensity: VisualDensity.compact, + ); + }, + ), + ), + // Main list + Expanded( + child: _buildList(state), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _onFabTapped(context), + tooltip: 'New', + child: const Icon(Icons.edit_outlined), + ), + ); + } + + Widget _buildList(KnowledgeState state) { + if (state.isLoadingIds && state.ids.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (state.error != null && state.ids.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Failed to load', style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref.read(knowledgeProvider.notifier).refresh(), + child: const Text('Retry'), + ), + ], + ), + ); + } + final items = state.orderedItems; + if (items.isEmpty && !state.isLoadingIds && !state.isLoadingBatch) { + return Center( + child: Text( + 'No ${_kTabs[_tabController.index].label.toLowerCase()} yet.', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } + return RefreshIndicator( + onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), + child: ListView.separated( + controller: _scrollController, + itemCount: items.length + (state.isLoadingBatch ? 1 : 0), + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + if (i >= items.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + return KnowledgeItemCard(item: items[i]); + }, + ), + ); + } + + void _onFabTapped(BuildContext context) { + final currentType = _kTabs[_tabController.index].type; + if (currentType == 'task') { + context.push('/tasks/new'); + return; + } + _showTypePicker(context); + } + + void _showTypePicker(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _TypePickerRow( + icon: Icons.description_outlined, + label: 'Note', + description: 'General note or document', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'note'}); + }, + ), + _TypePickerRow( + icon: Icons.person_outlined, + label: 'Person', + description: 'Contact, colleague, or reference person', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'person'}); + }, + ), + _TypePickerRow( + icon: Icons.place_outlined, + label: 'Place', + description: 'Location, venue, or place of interest', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'place'}); + }, + ), + _TypePickerRow( + icon: Icons.checklist_outlined, + label: 'List', + description: 'Checklist or structured list', + onTap: () { + Navigator.pop(sheetContext); + context.push('/notes/new', extra: {'noteType': 'list'}); + }, + ), + ], + ), + ), + ); + } +} + +class _TypePickerRow extends StatelessWidget { + final IconData icon; + final String label; + final String description; + final VoidCallback onTap; + + const _TypePickerRow({ + required this.icon, + required this.label, + required this.description, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Text(description), + onTap: onTap, + ); + } +} +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/screens/knowledge/knowledge_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/knowledge/knowledge_screen.dart +git commit -m "feat(knowledge): add KnowledgeScreen with two-tier feed, tabs, search, and tag filters" +``` + +--- + +## Task 8: NoteEditScreen — noteType Parameter + +**Files:** +- Modify: `lib/screens/notes/note_edit_screen.dart` + +The screen needs to: +1. Accept an optional `noteType` constructor parameter for new notes +2. Load `noteType` from the existing note when editing +3. Show a type badge chip in the app bar +4. Pass `noteType` to the provider on save + +- [ ] **Step 1: Update `lib/screens/notes/note_edit_screen.dart`** + +Change the class declaration and state to: + +```dart +class NoteEditScreen extends ConsumerStatefulWidget { + final int? noteId; + final String? noteType; // passed when creating a typed note from KnowledgeScreen + const NoteEditScreen({super.key, this.noteId, this.noteType}); + + @override + ConsumerState createState() => _NoteEditScreenState(); +} + +class _NoteEditScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _contentController = TextEditingController(); + final _tagController = TextEditingController(); + List _tags = []; + int? _projectId; + bool _preview = false; + bool _saving = false; + late String _noteType; // resolved in initState + + late final Future _initFuture; + + @override + void initState() { + super.initState(); + _noteType = widget.noteType ?? 'note'; + _initFuture = widget.noteId != null ? _loadExisting() : Future.value(); + } +``` + +Update `_loadExisting` to also load `noteType`: + +```dart + Future _loadExisting() async { + final note = + await ref.read(notesRepositoryProvider).getOne(widget.noteId!); + _titleController.text = note.title; + _contentController.text = note.body; + _tags = List.from(note.tags); + _projectId = note.projectId; + _noteType = note.noteType; + } +``` + +Update `_save` to pass `_noteType`: + +```dart + Future _save() async { + final title = _titleController.text.trim(); + final body = _contentController.text; + if (title.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + if (widget.noteId == null) { + await ref.read(notesProvider.notifier).create( + title, + body, + tags: _tags, + projectId: _projectId, + noteType: _noteType, + ); + if (mounted) context.pop(); + } else { + await ref.read(notesProvider.notifier).updateNote( + widget.noteId!, + title, + body, + tags: _tags, + projectId: _projectId, + clearProject: _projectId == null, + noteType: _noteType, + ); + if (mounted) context.pop(); + } + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } +``` + +Update `AppBar.title` to show the type badge: + +```dart + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.noteId == null ? 'New ${_typeName(_noteType)}' : 'Edit ${_typeName(_noteType)}'), + const SizedBox(width: 8), + if (_noteType != 'note') + Chip( + label: Text(_typeName(_noteType), + style: const TextStyle(fontSize: 11)), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), +``` + +Add the helper at the bottom of the class (outside the State class, as a top-level function or inside the file): + +```dart +String _typeName(String type) => switch (type) { + 'person' => 'Person', + 'place' => 'Place', + 'list' => 'List', + 'task' => 'Task', + _ => 'Note', + }; +``` + +Also update the GoRouter entry for `Routes.noteNew` in `app.dart` (done in Task 11) to read `extra` and pass it to `NoteEditScreen`. For now the screen compiles with the constructor as-is. + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/screens/notes/note_edit_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/notes/note_edit_screen.dart +git commit -m "feat(knowledge): thread noteType through NoteEditScreen for typed note creation" +``` + +--- + +## Task 9: Projects Sort + ProjectsScreen + ProjectEditScreen + +**Files:** +- Modify: `lib/data/api/projects_api.dart` +- Modify: `lib/data/repositories/projects_repository.dart` +- Modify: `lib/providers/projects_provider.dart` +- Create: `lib/screens/projects/projects_screen.dart` +- Create: `lib/screens/projects/project_edit_screen.dart` + +- [ ] **Step 1: Add sort params to `lib/data/api/projects_api.dart`** + +Update the `getAll` method: + +```dart + Future> getAll({ + String? status, + String sort = 'updated_at', + String order = 'desc', + }) async { + try { + final response = await _dio.get( + '/api/projects', + queryParameters: { + if (status != null) 'status': status, + 'sort': sort, + 'order': order, + }, + ); + final data = response.data as Map; + final list = data['projects'] as List; + return list + .map((e) => Project.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw dioToApp(e); + } + } +``` + +- [ ] **Step 2: Update `lib/data/repositories/projects_repository.dart`** + +```dart +import '../api/projects_api.dart'; +import '../models/project.dart'; + +class ProjectsRepository { + final ProjectsApi _api; + const ProjectsRepository(this._api); + + Future> getAll({ + String? status, + String sort = 'updated_at', + String order = 'desc', + }) => + _api.getAll(status: status, sort: sort, order: order); + + Future getOne(int id) => _api.getOne(id); + + Future create({ + required String title, + String? description, + String? goal, + String? color, + }) => + _api.create( + title: title, description: description, goal: goal, color: color); + + Future update(int id, Map fields) => + _api.update(id, fields); + + Future delete(int id) => _api.delete(id); +} +``` + +- [ ] **Step 3: Update `lib/providers/projects_provider.dart`** + +Update `build()` to pass sort params: + +```dart + @override + Future> build() async { + return ref.watch(projectsRepositoryProvider).getAll( + sort: 'updated_at', + order: 'desc', + ); + } +``` + +- [ ] **Step 4: Create `lib/screens/projects/projects_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/models/project.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectsScreen extends ConsumerWidget { + const ProjectsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final projectsAsync = ref.watch(projectsProvider); + return Scaffold( + appBar: AppBar(title: const Text('Projects')), + body: projectsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (projects) => projects.isEmpty + ? const Center(child: Text('No projects yet.')) + : RefreshIndicator( + onRefresh: () async => ref.invalidate(projectsProvider), + child: ListView.separated( + itemCount: projects.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => _ProjectCard(project: projects[i]), + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.push('/projects/new'), + tooltip: 'New project', + child: const Icon(Icons.add), + ), + ); + } +} + +class _ProjectCard extends StatelessWidget { + final Project project; + const _ProjectCard({required this.project}); + + Color _statusColor(BuildContext context) => switch (project.status) { + 'completed' => Colors.blue, + 'archived' => Colors.grey, + _ => Theme.of(context).colorScheme.primary, + }; + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(project.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (project.description?.isNotEmpty == true) + Text( + project.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (project.goal?.isNotEmpty == true) + Text( + project.goal!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + ], + ), + trailing: Chip( + label: Text( + project.status, + style: TextStyle( + fontSize: 11, + color: _statusColor(context), + ), + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onTap: () => context.push('/projects/${project.id}/tasks'), + ); + } +} +``` + +- [ ] **Step 5: Create `lib/screens/projects/project_edit_screen.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/exceptions.dart'; +import '../../providers/projects_provider.dart'; + +class ProjectEditScreen extends ConsumerStatefulWidget { + final int? projectId; + const ProjectEditScreen({super.key, this.projectId}); + + @override + ConsumerState createState() => _ProjectEditScreenState(); +} + +class _ProjectEditScreenState extends ConsumerState { + final _titleController = TextEditingController(); + final _descController = TextEditingController(); + final _goalController = TextEditingController(); + String _status = 'active'; + bool _saving = false; + late final Future _initFuture; + + @override + void initState() { + super.initState(); + _initFuture = + widget.projectId != null ? _loadExisting() : Future.value(); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + _goalController.dispose(); + super.dispose(); + } + + Future _loadExisting() async { + final projects = ref.read(projectsProvider).value ?? []; + final project = + projects.where((p) => p.id == widget.projectId).firstOrNull; + if (project != null) { + _titleController.text = project.title; + _descController.text = project.description ?? ''; + _goalController.text = project.goal ?? ''; + _status = project.status; + } + } + + Future _save() async { + final title = _titleController.text.trim(); + if (title.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Title is required.'))); + return; + } + setState(() => _saving = true); + try { + if (widget.projectId == null) { + await ref.read(projectsProvider.notifier).create( + title: title, + description: _descController.text.trim().isEmpty + ? null + : _descController.text.trim(), + goal: _goalController.text.trim().isEmpty + ? null + : _goalController.text.trim(), + ); + } else { + await ref.read(projectsProvider.notifier).updateProject( + widget.projectId!, + { + 'title': title, + 'description': _descController.text.trim(), + 'goal': _goalController.text.trim(), + 'status': _status, + }, + ); + } + if (mounted) context.pop(); + } on AppException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _initFuture, + builder: (context, snapshot) => Scaffold( + appBar: AppBar( + title: Text( + widget.projectId == null ? 'New Project' : 'Edit Project'), + actions: [ + IconButton( + icon: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + onPressed: _saving ? null : _save, + ), + ], + ), + body: snapshot.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Title *', border: OutlineInputBorder()), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder()), + maxLines: 3, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextField( + controller: _goalController, + decoration: const InputDecoration( + labelText: 'Goal', border: OutlineInputBorder()), + maxLines: 2, + textInputAction: TextInputAction.done, + ), + if (widget.projectId != null) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + value: _status, + decoration: const InputDecoration( + labelText: 'Status', + border: OutlineInputBorder()), + items: const [ + DropdownMenuItem( + value: 'active', child: Text('Active')), + DropdownMenuItem( + value: 'completed', child: Text('Completed')), + DropdownMenuItem( + value: 'archived', child: Text('Archived')), + ], + onChanged: (v) => setState(() => _status = v!), + ), + ], + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 6: Analyze** + +```bash +flutter analyze lib/data/api/projects_api.dart lib/data/repositories/projects_repository.dart lib/providers/projects_provider.dart lib/screens/projects/ +``` + +Expected: `No issues found!` + +- [ ] **Step 7: Commit** + +```bash +git add lib/data/api/projects_api.dart lib/data/repositories/projects_repository.dart lib/providers/projects_provider.dart lib/screens/projects/ +git commit -m "feat(knowledge): add ProjectsScreen and ProjectEditScreen, sort projects by updated_at" +``` + +--- + +## Task 10: ProjectTasksScreen — Edit Button + +**Files:** +- Modify: `lib/screens/library/project_tasks_screen.dart` + +- [ ] **Step 1: Add edit button to AppBar** + +In `project_tasks_screen.dart`, find the `AppBar` widget inside the `build` method (around line 71) and add `actions`: + +```dart + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project?.title ?? 'Project', + style: Theme.of(context).textTheme.titleLarge, + ), + if (project?.description?.isNotEmpty == true) + Text( + project!.description!, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit project', + onPressed: () => + context.push('/projects/${widget.projectId}/edit'), + ), + ], + ), +``` + +- [ ] **Step 2: Analyze** + +```bash +flutter analyze lib/screens/library/project_tasks_screen.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/library/project_tasks_screen.dart +git commit -m "feat(knowledge): add edit button to ProjectTasksScreen app bar" +``` + +--- + +## Task 11: Shell Navigation — Wire 4 Tabs & Retire LibraryScreen + +**Files:** +- Modify: `lib/app.dart` + +This is the final wiring task. It replaces the Library shell route with Knowledge and Projects, updates the bottom nav to 4 tabs, and passes `noteType` from `GoRouter.extra` to `NoteEditScreen`. + +- [ ] **Step 1: Update imports in `lib/app.dart`** + +Add these imports (remove the `library_screen.dart` import): + +```dart +import 'screens/knowledge/knowledge_screen.dart'; +import 'screens/projects/projects_screen.dart'; +import 'screens/projects/project_edit_screen.dart'; +``` + +Remove: +```dart +import 'screens/library/library_screen.dart'; +``` + +- [ ] **Step 2: Update the GoRouter routes** + +Replace the `ShellRoute` block and the `Routes.projectTasks` route with: + +```dart + GoRoute( + path: Routes.projectTasks, + builder: (_, state) => ProjectTasksScreen( + projectId: int.parse(state.pathParameters['id']!), + ), + ), + GoRoute( + path: '/projects/new', + builder: (_, _) => const ProjectEditScreen(), + ), + GoRoute( + path: Routes.projectEdit, + builder: (_, state) => ProjectEditScreen( + projectId: int.parse(state.pathParameters['id']!), + ), + ), + 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(), + ), + ], + ), +``` + +Update the `Routes.noteNew` route to pass `noteType` from `extra`: + +```dart + GoRoute( + path: Routes.noteNew, + builder: (_, state) { + final extra = state.extra as Map?; + return NoteEditScreen(noteType: extra?['noteType'] as String?); + }, + ), +``` + +- [ ] **Step 3: Update `_Shell._tabs` and navigation bars** + +Replace the `_tabs` list and all four `NavigationDestination` / `NavigationRailDestination` entries: + +```dart + static const _tabs = [ + Routes.briefing, + Routes.knowledge, + Routes.conversations, + Routes.projects, + ]; +``` + +Bottom `NavigationBar` destinations: + +```dart + 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', + ), + ], +``` + +Wide-screen `NavigationRail` destinations: + +```dart + 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'), + ), + ], +``` + +Update `_hintForLocation`: + +```dart + String _hintForLocation(String location) { + if (location.startsWith(Routes.knowledge)) return 'Capture a note…'; + if (location.startsWith(Routes.projects)) return 'Capture a note…'; + if (location.startsWith(Routes.conversations)) return 'Ask Fabled…'; + return 'Capture a note…'; + } +``` + +- [ ] **Step 4: Analyze** + +```bash +flutter analyze lib/app.dart +``` + +Expected: `No issues found!` + +- [ ] **Step 5: Full build** + +```bash +flutter build apk --debug +``` + +Expected: Build succeeds with `Built build/app/outputs/flutter-apk/app-debug.apk` + +- [ ] **Step 6: Delete retired files** + +```bash +rm lib/screens/library/library_screen.dart +rm lib/widgets/library_item_card.dart +``` + +- [ ] **Step 7: Analyze again (confirms no remaining references to deleted files)** + +```bash +flutter analyze +``` + +Expected: `No issues found!` + +- [ ] **Step 8: Final build** + +```bash +flutter build apk --debug +``` + +Expected: `Built build/app/outputs/flutter-apk/app-debug.apk` + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature" +``` diff --git a/lib/app.dart b/lib/app.dart index 2164a41..009d840 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,11 +17,13 @@ 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'; import 'screens/library/project_tasks_screen.dart'; import 'screens/chat/chat_screen.dart'; import 'screens/chat/conversations_tab_screen.dart'; -import 'screens/library/library_screen.dart'; import 'screens/notes/note_detail_screen.dart'; +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/setup/setup_screen.dart'; @@ -82,7 +84,10 @@ final routerProvider = Provider((ref) { ), GoRoute( path: Routes.noteNew, - builder: (_, _) => const NoteEditScreen(), + builder: (_, state) { + final extra = state.extra as Map?; + return NoteEditScreen(noteType: extra?['noteType'] as String?); + }, ), GoRoute( path: Routes.noteDetail, @@ -116,6 +121,16 @@ final routerProvider = Provider((ref) { projectId: int.parse(state.pathParameters['id']!), ), ), + GoRoute( + path: '/projects/new', + builder: (_, _) => const ProjectEditScreen(), + ), + GoRoute( + path: Routes.projectEdit, + builder: (_, state) => ProjectEditScreen( + projectId: int.parse(state.pathParameters['id']!), + ), + ), GoRoute( path: Routes.chat, builder: (_, state) => ChatScreen( @@ -130,13 +145,17 @@ final routerProvider = Provider((ref) { builder: (_, _) => const BriefingScreen(), ), GoRoute( - path: Routes.library, - builder: (_, _) => const LibraryScreen(), + path: Routes.knowledge, + builder: (_, _) => const KnowledgeScreen(), ), GoRoute( path: Routes.conversations, builder: (_, _) => const ConversationsTabScreen(), ), + GoRoute( + path: Routes.projects, + builder: (_, _) => const ProjectsScreen(), + ), ], ), ], @@ -154,8 +173,9 @@ class _Shell extends ConsumerStatefulWidget { class _ShellState extends ConsumerState<_Shell> { static const _tabs = [ Routes.briefing, - Routes.library, + Routes.knowledge, Routes.conversations, + Routes.projects, ]; @override @@ -290,15 +310,20 @@ class _ShellState extends ConsumerState<_Shell> { label: Text('Briefing'), ), NavigationRailDestination( - icon: Icon(Icons.library_books_outlined), - selectedIcon: Icon(Icons.library_books), - label: Text('Library'), + 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'), + ), ], ), const VerticalDivider(width: 1), @@ -335,15 +360,20 @@ class _ShellState extends ConsumerState<_Shell> { label: 'Briefing', ), NavigationDestination( - icon: Icon(Icons.library_books_outlined), - selectedIcon: Icon(Icons.library_books), - label: 'Library', + 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', + ), ], ), ); @@ -411,8 +441,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { } String _hintForLocation(String location) { - if (location.startsWith(Routes.library) && - location.contains('tasks')) { return 'Add a task…'; } + if (location.startsWith(Routes.knowledge)) return 'Capture a note…'; + if (location.startsWith(Routes.projects)) return 'Capture a note…'; if (location.startsWith(Routes.conversations)) return 'Ask Fabled…'; return 'Capture a note…'; } diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart deleted file mode 100644 index e39a216..0000000 --- a/lib/screens/library/library_screen.dart +++ /dev/null @@ -1,294 +0,0 @@ -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/note.dart'; -import '../../data/models/task.dart'; -import '../../providers/notes_provider.dart'; -import '../../providers/projects_provider.dart'; -import '../../providers/tasks_provider.dart'; -import '../../widgets/library_item_card.dart'; - -enum _LibraryFilter { all, notes, tasks, projects } - -enum _TaskStatusFilter { all, todo, inProgress, done } - -class LibraryScreen extends ConsumerStatefulWidget { - const LibraryScreen({super.key}); - - @override - ConsumerState createState() => _LibraryScreenState(); -} - -class _LibraryScreenState extends ConsumerState { - _LibraryFilter _filter = _LibraryFilter.all; - _TaskStatusFilter _taskStatus = _TaskStatusFilter.all; - bool _searchActive = false; - String _searchQuery = ''; - final _searchController = TextEditingController(); - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - bool _matchesSearch(String text) { - if (_searchQuery.isEmpty) return true; - return text.toLowerCase().contains(_searchQuery.toLowerCase()); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final notesAsync = ref.watch(notesProvider); - final tasksAsync = ref.watch(tasksProvider); - final projectsAsync = ref.watch(projectsProvider); - - return Scaffold( - appBar: AppBar( - title: _searchActive - ? TextField( - controller: _searchController, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Search…', - border: InputBorder.none, - isDense: true, - ), - onChanged: (q) => setState(() => _searchQuery = q), - ) - : Text('Library', style: theme.textTheme.titleLarge), - actions: [ - IconButton( - icon: Icon(_searchActive ? Icons.close : Icons.search), - onPressed: () => setState(() { - _searchActive = !_searchActive; - if (!_searchActive) { - _searchQuery = ''; - _searchController.clear(); - } - }), - ), - ], - ), - body: Column( - children: [ - // ── Filter pills ────────────────────────────────────────────────── - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), - child: Row( - children: _LibraryFilter.values.map((f) { - final label = switch (f) { - _LibraryFilter.all => 'All', - _LibraryFilter.notes => 'Notes', - _LibraryFilter.tasks => 'Tasks', - _LibraryFilter.projects => 'Projects', - }; - final selected = _filter == f; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text(label), - selected: selected, - onSelected: (_) => setState(() { - _filter = f; - _taskStatus = _TaskStatusFilter.all; - }), - selectedColor: - theme.colorScheme.primary.withValues(alpha: 0.18), - checkmarkColor: theme.colorScheme.primary, - ), - ); - }).toList(), - ), - ), - - // ── Task status sub-filter (Tasks pill only) ─────────────────── - if (_filter == _LibraryFilter.tasks) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), - child: Row( - children: _TaskStatusFilter.values.map((s) { - final label = switch (s) { - _TaskStatusFilter.all => 'All', - _TaskStatusFilter.todo => 'To Do', - _TaskStatusFilter.inProgress => 'In Progress', - _TaskStatusFilter.done => 'Done', - }; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(label), - selected: _taskStatus == s, - onSelected: (_) => setState(() => _taskStatus = s), - selectedColor: - theme.colorScheme.secondary.withValues(alpha: 0.15), - ), - ); - }).toList(), - ), - ), - - const Divider(height: 1), - - // ── Content ─────────────────────────────────────────────────────── - Expanded( - child: switch (_filter) { - _LibraryFilter.notes => _buildNotesList(notesAsync), - _LibraryFilter.tasks => _buildTasksList(tasksAsync), - _LibraryFilter.projects => _buildProjectsList(projectsAsync), - _LibraryFilter.all => _buildAllList(notesAsync, tasksAsync), - }, - ), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: () => _showCreateSheet(context), - child: const Icon(Icons.add), - ), - ); - } - - Widget _buildNotesList(AsyncValue> notesAsync) { - return notesAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (notes) { - final filtered = notes - .where((n) => _matchesSearch(n.title) || _matchesSearch(n.body)) - .toList(); - if (filtered.isEmpty) { - return const Center(child: Text('No notes found')); - } - return RefreshIndicator( - onRefresh: () async => ref.invalidate(notesProvider), - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]), - ), - ); - }, - ); - } - - Widget _buildTasksList(AsyncValue> tasksAsync) { - return tasksAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (tasks) { - var filtered = tasks.where((t) => _matchesSearch(t.title)).toList(); - if (_taskStatus != _TaskStatusFilter.all) { - final status = switch (_taskStatus) { - _TaskStatusFilter.todo => TaskStatus.todo, - _TaskStatusFilter.inProgress => TaskStatus.inProgress, - _TaskStatusFilter.done => TaskStatus.done, - _ => TaskStatus.todo, - }; - filtered = filtered.where((t) => t.status == status).toList(); - } - if (filtered.isEmpty) { - return const Center(child: Text('No tasks found')); - } - return RefreshIndicator( - onRefresh: () async => ref.invalidate(tasksProvider), - child: ListView.builder( - itemCount: filtered.length, - itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]), - ), - ); - }, - ); - } - - Widget _buildProjectsList(AsyncValue> projectsAsync) { - return projectsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (projects) { - if (projects.isEmpty) { - return const Center(child: Text('No projects')); - } - return RefreshIndicator( - onRefresh: () async => ref.invalidate(projectsProvider), - child: ListView.builder( - itemCount: projects.length, - itemBuilder: (_, i) => - ProjectLibraryCard(project: projects[i]), - ), - ); - }, - ); - } - - Widget _buildAllList( - AsyncValue> notesAsync, - AsyncValue> tasksAsync, - ) { - final notes = notesAsync.value ?? []; - final tasks = tasksAsync.value ?? []; - - // Merge and sort by updatedAt desc - final items = <(DateTime, Widget)>[]; - for (final n in notes) { - if (_matchesSearch(n.title) || _matchesSearch(n.body)) { - items.add((n.updatedAt, NoteLibraryCard(note: n))); - } - } - for (final t in tasks) { - if (_matchesSearch(t.title)) { - items.add((t.updatedAt, TaskLibraryCard(task: t))); - } - } - items.sort((a, b) => b.$1.compareTo(a.$1)); - - if (notesAsync.isLoading || tasksAsync.isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (items.isEmpty) { - return const Center(child: Text('Nothing here yet')); - } - return RefreshIndicator( - onRefresh: () async { - ref.invalidate(notesProvider); - ref.invalidate(tasksProvider); - }, - child: ListView.builder( - itemCount: items.length, - itemBuilder: (_, i) => items[i].$2, - ), - ); - } - - void _showCreateSheet(BuildContext context) { - showModalBottomSheet( - context: context, - builder: (_) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.article_outlined), - title: const Text('New note'), - onTap: () { - Navigator.pop(context); - context.push(Routes.noteNew); - }, - ), - ListTile( - leading: const Icon(Icons.check_box_outlined), - title: const Text('New task'), - onTap: () { - Navigator.pop(context); - context.push(Routes.taskNew); - }, - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/library_item_card.dart b/lib/widgets/library_item_card.dart deleted file mode 100644 index ef0a970..0000000 --- a/lib/widgets/library_item_card.dart +++ /dev/null @@ -1,272 +0,0 @@ -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/note.dart'; -import '../data/models/project.dart'; -import '../data/models/task.dart'; -import '../providers/tasks_provider.dart'; - -// ── Note card ──────────────────────────────────────────────────────────────── - -class NoteLibraryCard extends StatelessWidget { - final Note note; - const NoteLibraryCard({super.key, required this.note}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: InkWell( - onTap: () => context - .push(Routes.noteDetail.replaceFirst(':id', '${note.id}')), - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.article_outlined, - size: 15, color: theme.colorScheme.onSurfaceVariant), - const SizedBox(width: 6), - Expanded( - child: Text( - note.title.isNotEmpty ? note.title : 'Untitled', - style: theme.textTheme.titleSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - _relativeTime(note.updatedAt), - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant), - ), - ], - ), - if (note.body.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - note.body.replaceAll('\n', ' '), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - if (note.tags.isNotEmpty) ...[ - const SizedBox(height: 6), - Wrap( - spacing: 4, - runSpacing: 2, - children: note.tags - .take(4) - .map((t) => Chip( - label: Text(t), - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - )) - .toList(), - ), - ], - ], - ), - ), - ), - ); - } -} - -// ── Task card ──────────────────────────────────────────────────────────────── - -class TaskLibraryCard extends ConsumerWidget { - final Task task; - const TaskLibraryCard({super.key, required this.task}); - - Color _priorityColor(BuildContext context) { - return switch (task.priority) { - TaskPriority.high => const Color(0xFFEF4444), - TaskPriority.medium => const Color(0xFFF59E0B), - _ => Theme.of(context).colorScheme.onSurfaceVariant, - }; - } - - IconData get _statusIcon => switch (task.status) { - TaskStatus.done => Icons.check_circle, - TaskStatus.inProgress => Icons.timelapse, - _ => Icons.radio_button_unchecked, - }; - - Color _statusColor(BuildContext context) { - final cs = Theme.of(context).colorScheme; - return switch (task.status) { - TaskStatus.done => const Color(0xFF22C55E), - TaskStatus.inProgress => cs.primary, - _ => cs.onSurfaceVariant, - }; - } - - TaskStatus get _nextStatus => switch (task.status) { - TaskStatus.todo => TaskStatus.inProgress, - TaskStatus.inProgress => TaskStatus.done, - _ => TaskStatus.todo, - }; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: InkWell( - onTap: () => context - .push(Routes.taskEdit.replaceFirst(':id', '${task.id}')), - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 10, 14, 10), - child: Row( - children: [ - // Status cycle button - IconButton( - icon: Icon(_statusIcon, color: _statusColor(context)), - onPressed: () => ref - .read(tasksProvider.notifier) - .updateTask(task.id, {'status': _nextStatus.value}), - tooltip: 'Cycle status', - visualDensity: VisualDensity.compact, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - task.title.isNotEmpty ? task.title : 'Untitled', - style: theme.textTheme.titleSmall?.copyWith( - decoration: task.status == TaskStatus.done - ? TextDecoration.lineThrough - : null, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (task.dueDate != null) ...[ - const SizedBox(height: 2), - Text( - 'Due ${_formatDate(task.dueDate!)}', - style: theme.textTheme.labelSmall?.copyWith( - color: task.dueDate!.isBefore(DateTime.now()) && - task.status != TaskStatus.done - ? const Color(0xFFEF4444) - : theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - if (task.priority != TaskPriority.none && - task.priority != TaskPriority.low) - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: _priorityColor(context), - shape: BoxShape.circle, - ), - ), - ], - ), - ), - ), - ); - } -} - -// ── Project card ───────────────────────────────────────────────────────────── - -class ProjectLibraryCard extends StatelessWidget { - final Project project; - const ProjectLibraryCard({super.key, required this.project}); - - Color _parseColor(String? hex) { - if (hex == null || hex.isEmpty) return const Color(0xFF6366F1); - try { - return Color(int.parse(hex.replaceFirst('#', '0xFF'))); - } catch (_) { - return const Color(0xFF6366F1); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final color = _parseColor(project.color); - return Card( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => context - .push('/projects/${project.id}/tasks'), - borderRadius: BorderRadius.circular(14), - child: Row( - children: [ - // Colour strip - Container(width: 6, height: 64, color: color), - const SizedBox(width: 12), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - project.title, - style: theme.textTheme.titleSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (project.description?.isNotEmpty == true) ...[ - const SizedBox(height: 2), - Text( - project.description!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ), - const SizedBox(width: 12), - ], - ), - ), - ); - } -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -String _relativeTime(DateTime dt) { - final diff = DateTime.now().difference(dt); - if (diff.inMinutes < 1) return 'just now'; - if (diff.inHours < 1) return '${diff.inMinutes}m ago'; - if (diff.inDays < 1) return '${diff.inHours}h ago'; - if (diff.inDays < 7) return '${diff.inDays}d ago'; - return _formatDate(dt); -} - -String _formatDate(DateTime dt) { - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' - ]; - return '${months[dt.month - 1]} ${dt.day}'; -} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index eb768d3..bcdce0a 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); + flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); 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) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index eb06039..8c4a3aa 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_timezone open_file_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 104d10e..bd01ad7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,13 +6,17 @@ import FlutterMacOS import Foundation import flutter_inappwebview_macos +import flutter_timezone import open_file_mac import package_info_plus import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index bfc5ee8..ea7ca70 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -957,6 +957,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 3b4ee90..cb273b1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,17 @@ #include "generated_plugin_registrant.h" #include +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); + FlutterTimezonePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 61c79a2..9474322 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,9 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_inappwebview_windows + flutter_timezone + permission_handler_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 3a3f44b00b6176a1ffae8b044646fb312d7a6d30 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:19:48 -0400 Subject: [PATCH 14/24] docs: add Android Voice I/O design spec Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + .../2026-04-05-android-voice-io-design.md | 180 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-05-android-voice-io-design.md diff --git a/.gitignore b/.gitignore index f8554ec..46bdfa0 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +.superpowers/ diff --git a/docs/superpowers/specs/2026-04-05-android-voice-io-design.md b/docs/superpowers/specs/2026-04-05-android-voice-io-design.md new file mode 100644 index 0000000..1e5c8c7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-android-voice-io-design.md @@ -0,0 +1,180 @@ +# Android Voice I/O Design + +## Goal + +Add voice input (STT) and output (TTS) to the Android app. All audio processing runs server-side via existing backend endpoints — no on-device STT or TTS APIs. Voice input is available in three locations: the Chat screen, the Quick Capture bar, and the Briefing follow-up bar. TTS auto-plays when voice mode is active. + +--- + +## Architecture + +### New files + +| File | Responsibility | +|---|---| +| `lib/data/api/voice_api.dart` | Dio wrapper: `checkStatus()`, `transcribe(Uint8List)`, `synthesise(String)` | +| `lib/data/repositories/voice_repository.dart` | Thin wrapper around `VoiceApi` | +| `lib/providers/voice_provider.dart` | `VoiceState` + `VoiceNotifier extends Notifier` — owns full recording/TTS lifecycle | +| `lib/widgets/voice_mic_button.dart` | Shared mic button widget used by all three screens; animates across all states | + +### Modified files + +| File | Change | +|---|---| +| `pubspec.yaml` | Add `record: ^6.x`, `just_audio: ^0.9.x` | +| `android/app/src/main/AndroidManifest.xml` | Add `RECORD_AUDIO` permission | +| `lib/providers/api_client_provider.dart` | Add `voiceApiProvider`, `voiceRepositoryProvider` | +| `lib/screens/chat/chat_screen.dart` | Add `VoiceMicButton` to input bar; wire streaming TTS watcher | +| `lib/app.dart` (`_QuickCaptureBar`) | Add `VoiceMicButton` next to capture send button | +| `lib/screens/briefing/briefing_screen.dart` | Add `VoiceMicButton` to follow-up input row; wire streaming TTS watcher | + +### Packages + +- **`record` ^6.x** — records audio on Android, outputs WebM/Opus (matches what the backend Whisper STT expects) +- **`just_audio` ^0.9.x** — queue-based audio player; plays WAV bytes returned by `/api/voice/synthesise` + +--- + +## State model + +```dart +enum VoiceMode { idle, recording, transcribing, playing } + +class VoiceState { + final VoiceMode mode; + final bool voiceModeActive; // whether the voice loop is running + final bool available; // from /api/voice/status check +} +``` + +`VoiceNotifier` is a `Notifier` (Riverpod 3). It owns the `AudioRecorder` and `AudioPlayer` instances. + +--- + +## Backend endpoints (no changes needed) + +All existing, no backend work required: + +- `GET /api/voice/status` → `{enabled, stt, tts}` — checked before entering voice mode +- `POST /api/voice/transcribe` — `multipart/form-data`, field `audio` (WebM/Opus bytes) → `{transcript, duration_ms}` +- `POST /api/voice/synthesise` — `{"text": "..."}` → `audio/wav` bytes + +--- + +## Data flow + +### Chat / Briefing — continuous voice loop + +1. User taps mic → `VoiceNotifier.enterVoiceMode()` +2. Call `GET /api/voice/status`; if unavailable → show snackbar, abort +3. Request `RECORD_AUDIO` permission (via `permission_handler`); if denied → snackbar, abort +4. Set `voiceModeActive = true`; input field disabled, send button dimmed, red banner shown +5. Start recording via `record` package; poll amplitude every 200ms +6. Silence detected (amplitude < −40 dB for 1500ms) → stop recording +7. `POST /api/voice/transcribe` with WebM bytes → transcript +8. If transcript is empty → restart listening from step 5 silently +9. Call `sendMessage(transcript)` on `messagesProvider` (identical path to typed text) +10. As the SSE response streams in, `VoiceNotifier` watches the streaming content: + - Buffer incoming text; extract completed sentences at `.`, `!`, `?` boundaries + - Strip markdown (code fences, headers, bold/italic markers) before synthesising + - For each sentence: `POST /api/voice/synthesise` → enqueue WAV blob in `just_audio` player +11. After all audio plays → restart listening from step 5 +12. User taps mic again → `VoiceNotifier.exitVoiceMode()` → stop recording, cancel pending TTS, reset state + +### Quick Capture — one-shot, no TTS loop + +Steps 1–8 identical. Then instead of `sendMessage`, the transcript is passed to `captureWorkQueueProvider.enqueue(transcript)` — identical to typing in the capture bar. Mic returns to idle. No TTS playback, no loop. + +--- + +## Silence detection + +The `record` package emits `onAmplitudeChanged` events. `VoiceNotifier` tracks consecutive below-threshold samples: + +- Threshold: −40 dB +- Required duration: 1500ms of continuous silence +- Minimum recording length: 300ms (ignore silence before user has spoken) + +--- + +## Streaming TTS (mirrors web app) + +Matches the behaviour of `useStreamingTts` in the web frontend: + +1. Watch `messagesProvider` for streaming assistant content +2. Accumulate new characters into a sentence buffer +3. On each sentence boundary → strip markdown → `POST /api/voice/synthesise` → enqueue WAV +4. `just_audio` plays queued WAVs sequentially +5. On new message start → cancel in-flight synthesis, clear queue, stop playback +6. On stream end → flush remaining buffer fragment if ≥ 3 characters + +Markdown stripping removes: code blocks (`` ``` ``), inline code, `#` headers, `**bold**`, `*italic*`, `[link](url)` → link text only, leading list markers. + +--- + +## UX + +### VoiceMicButton states + +| State | Visual | +|---|---| +| Idle (voice mode off) | Plain mic icon, muted background | +| Recording | Red filled circle, pulsing shadow ring | +| Transcribing | Indigo filled circle, spinner overlay | +| Playing TTS | Indigo filled circle, speaker wave icon | + +### Voice mode banner (Chat + Briefing only) + +A thin red banner appears above the input row while voice mode is active: +> "🎤 Listening… tap mic to exit voice mode" + +Input field shows "Listening…" hint in italic. Send button dimmed but still tappable as an override. + +### Quick Capture bar + +No banner. The capture bar's background shifts to a subtle red tint while recording. Returns to normal after capture. + +--- + +## Error handling + +| Scenario | Behaviour | +|---|---| +| Voice unavailable on server | Snackbar "Voice not available on this server", mic stays idle | +| Mic permission denied | Snackbar "Microphone permission required", mic stays idle | +| Empty transcript | Stay in voice mode, restart listening silently | +| Transcription API error | Stay in voice mode, restart listening; log warning | +| TTS synthesis failure for a sentence | Skip that sentence, continue playback queue | +| Network error during voice loop | Exit voice mode, show snackbar "Voice error — check connection" | +| User navigates away while in voice mode | `VoiceNotifier` disposes recording + playback cleanly | + +--- + +## Permissions + +Add to `android/app/src/main/AndroidManifest.xml`: + +```xml + +``` + +Runtime permission requested via `permission_handler` at first mic tap. If permanently denied, open app settings. + +--- + +## Testing + +- **Unit — `VoiceNotifier`:** Mock `VoiceRepository`; verify state transitions: idle → recording → transcribing → idle (on empty transcript), idle → recording → transcribing → playing → recording (happy path) +- **Unit — silence detection:** Feed synthetic amplitude stream; assert stop fires after 1500ms below threshold, not before +- **Unit — streaming TTS sentence extraction:** Feed streaming content strings; assert correct sentences extracted, markdown stripped +- **Widget — `VoiceMicButton`:** Verify correct icon/colour/animation for each `VoiceMode` value +- **Integration — permission flow:** Mock `permission_handler`; assert denied path shows snackbar and stays idle + +--- + +## Out of scope + +- Wake word / always-on listening +- On-device STT or TTS +- Voice settings UI in the Android app (voice and TTS settings managed via the web Settings page) +- iOS support (Android only per project constraints) From 89fe8994fb714cd9babbeb696214c42ca387c142 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:36:48 -0400 Subject: [PATCH 15/24] docs: add Android Voice I/O implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-04-05-android-voice-io.md | 1511 +++++++++++++++++ 1 file changed, 1511 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-android-voice-io.md diff --git a/docs/superpowers/plans/2026-04-05-android-voice-io.md b/docs/superpowers/plans/2026-04-05-android-voice-io.md new file mode 100644 index 0000000..cbe02a8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-android-voice-io.md @@ -0,0 +1,1511 @@ +# Android Voice I/O 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:** Add server-side STT and streaming TTS to the Android app — mic button in Chat, Quick Capture bar, and Briefing. + +**Architecture:** A shared `VoiceNotifier` (Riverpod `Notifier`) owns the recording lifecycle, silence detection, and TTS sentence queue. Screens pass an `onTranscript` callback when entering voice mode; TTS is fed back sentence-by-sentence via `feedContent()` as the SSE response streams in. All audio I/O goes through the existing `/api/voice/*` endpoints — no backend changes needed. + +**Tech Stack:** Flutter/Dart, `record ^5.0.0` (WebM/Opus recording), `just_audio ^0.9.x` (WAV playback), `permission_handler ^11.x` (already in pubspec), `path_provider ^2.x` (already in pubspec), Riverpod 3, Dio. + +--- + +## File map + +| Action | Path | Responsibility | +|---|---|---| +| Create | `lib/data/api/voice_api.dart` | Dio calls: checkStatus, transcribe, synthesise | +| Create | `lib/data/repositories/voice_repository.dart` | Thin wrapper around VoiceApi | +| Modify | `lib/providers/api_client_provider.dart` | Add voiceApiProvider, voiceRepositoryProvider | +| Create | `lib/providers/voice_provider.dart` | VoiceState + VoiceNotifier (recording, silence, TTS) | +| Create | `lib/widgets/voice_mic_button.dart` | Animated mic button widget | +| Modify | `lib/screens/chat/chat_screen.dart` | Add mic button + voice banner + TTS feed | +| Modify | `lib/app.dart` | Add mic button to _QuickCaptureBar | +| Modify | `lib/screens/briefing/briefing_screen.dart` | Add mic button + voice banner + TTS feed | +| Modify | `pubspec.yaml` | Add record, just_audio | +| Modify | `android/app/src/main/AndroidManifest.xml` | RECORD_AUDIO permission | +| Modify | `test/widget_test.dart` | Unit tests for pure voice logic | + +--- + +## Task 1: Dependencies and permissions + +**Files:** +- Modify: `pubspec.yaml` +- Modify: `android/app/src/main/AndroidManifest.xml` + +- [ ] **Step 1: Add packages to pubspec.yaml** + +In `pubspec.yaml`, add under `dependencies:` (after `url_launcher`): + +```yaml + record: ^5.0.0 + just_audio: ^0.9.39 +``` + +- [ ] **Step 2: Add RECORD_AUDIO permission to AndroidManifest.xml** + +In `android/app/src/main/AndroidManifest.xml`, add after the existing `` line: + +```xml + +``` + +- [ ] **Step 3: Install packages** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter pub get +``` + +Expected: resolves `record 5.x` and `just_audio 0.9.x` without conflicts. + +- [ ] **Step 4: Verify no compile errors** + +```bash +flutter analyze +``` + +Expected: no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add pubspec.yaml pubspec.lock android/app/src/main/AndroidManifest.xml +git commit -m "feat(voice): add record + just_audio dependencies, RECORD_AUDIO permission" +``` + +--- + +## Task 2: VoiceApi and VoiceStatus model + +**Files:** +- Create: `lib/data/api/voice_api.dart` +- Modify: `test/widget_test.dart` + +- [ ] **Step 1: Write failing tests for VoiceStatus.fromJson** + +Add to `test/widget_test.dart`: + +```dart +import 'package:fabled_app/data/api/voice_api.dart'; +``` + +Add a new group at the bottom of `main()`: + +```dart + group('VoiceStatus.fromJson', () { + test('parses enabled with stt and tts available', () { + final status = VoiceStatus.fromJson({ + 'enabled': true, + 'stt': true, + 'tts': true, + }); + expect(status.enabled, isTrue); + expect(status.stt, isTrue); + expect(status.tts, isTrue); + }); + + test('parses disabled state', () { + final status = VoiceStatus.fromJson({ + 'enabled': false, + 'stt': false, + 'tts': false, + }); + expect(status.enabled, isFalse); + }); + + test('fully unavailable when enabled but stt or tts false', () { + final status = VoiceStatus.fromJson({ + 'enabled': true, + 'stt': false, + 'tts': true, + }); + expect(status.fullyAvailable, isFalse); + }); + }); +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +flutter test test/widget_test.dart +``` + +Expected: FAIL — `voice_api.dart` not found. + +- [ ] **Step 3: Create lib/data/api/voice_api.dart** + +```dart +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +import 'api_client.dart'; + +class VoiceStatus { + final bool enabled; + final bool stt; + final bool tts; + + const VoiceStatus({ + required this.enabled, + required this.stt, + required this.tts, + }); + + /// True only when voice is enabled AND both STT and TTS are ready. + bool get fullyAvailable => enabled && stt && tts; + + factory VoiceStatus.fromJson(Map json) => VoiceStatus( + enabled: json['enabled'] as bool? ?? false, + stt: json['stt'] as bool? ?? false, + tts: json['tts'] as bool? ?? false, + ); +} + +class VoiceApi { + final Dio _dio; + const VoiceApi(this._dio); + + /// Check whether voice features are available on this server. + Future checkStatus() async { + try { + final response = await _dio.get('/api/voice/status'); + return VoiceStatus.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST WebM/Opus audio bytes and return the transcript string. + /// Returns empty string on empty or error response. + Future transcribe(Uint8List audioBytes) async { + try { + final formData = FormData.fromMap({ + 'audio': MultipartFile.fromBytes( + audioBytes, + filename: 'audio.webm', + contentType: DioMediaType('audio', 'webm'), + ), + }); + final response = await _dio.post( + '/api/voice/transcribe', + data: formData, + options: Options( + receiveTimeout: const Duration(seconds: 60), + contentType: 'multipart/form-data', + ), + ); + final data = response.data as Map; + return (data['transcript'] as String? ?? '').trim(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST text and return raw WAV bytes. + Future synthesise(String text) async { + try { + final response = await _dio.post( + '/api/voice/synthesise', + data: {'text': text}, + options: Options(responseType: ResponseType.bytes), + ); + return Uint8List.fromList(response.data as List); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +flutter test test/widget_test.dart +``` + +Expected: all tests pass including the new VoiceStatus group. + +- [ ] **Step 5: Commit** + +```bash +git add lib/data/api/voice_api.dart test/widget_test.dart +git commit -m "feat(voice): add VoiceApi and VoiceStatus model" +``` + +--- + +## Task 3: VoiceRepository and provider registration + +**Files:** +- Create: `lib/data/repositories/voice_repository.dart` +- Modify: `lib/providers/api_client_provider.dart` + +- [ ] **Step 1: Create lib/data/repositories/voice_repository.dart** + +```dart +import 'dart:typed_data'; + +import '../api/voice_api.dart'; + +class VoiceRepository { + final VoiceApi _api; + const VoiceRepository(this._api); + + Future checkStatus() => _api.checkStatus(); + Future transcribe(Uint8List audioBytes) => _api.transcribe(audioBytes); + Future synthesise(String text) => _api.synthesise(text); +} +``` + +- [ ] **Step 2: Register providers in lib/providers/api_client_provider.dart** + +Add the following imports at the top of the file (after the existing imports): + +```dart +import '../data/api/voice_api.dart'; +import '../data/repositories/voice_repository.dart'; +``` + +Add at the bottom of the file (after `settingsApiProvider`): + +```dart +final voiceApiProvider = Provider((ref) { + return VoiceApi(ref.watch(dioProvider)); +}); + +final voiceRepositoryProvider = Provider((ref) { + return VoiceRepository(ref.watch(voiceApiProvider)); +}); +``` + +- [ ] **Step 3: Verify no errors** + +```bash +flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add lib/data/repositories/voice_repository.dart lib/providers/api_client_provider.dart +git commit -m "feat(voice): add VoiceRepository and provider registration" +``` + +--- + +## Task 4: VoiceNotifier — state, recording, silence detection, transcription + +**Files:** +- Create: `lib/providers/voice_provider.dart` +- Modify: `test/widget_test.dart` + +- [ ] **Step 1: Write failing tests for sentence extraction and markdown stripping** + +Add the following import to `test/widget_test.dart`: + +```dart +import 'package:fabled_app/providers/voice_provider.dart'; +``` + +Add a new group at the bottom of `main()`: + +```dart + group('VoiceNotifier sentence extraction', () { + test('extracts complete sentences at full stops', () { + final result = extractSentences('Hello world. How are you? I am fine!'); + expect(result.sentences, equals(['Hello world.', 'How are you?', 'I am fine!'])); + expect(result.remainder, equals('')); + }); + + test('leaves incomplete fragment in remainder', () { + final result = extractSentences('Hello world. Incomplete'); + expect(result.sentences, equals(['Hello world.'])); + expect(result.remainder, equals('Incomplete')); + }); + + test('returns empty sentences and full text when no boundary', () { + final result = extractSentences('No boundary here'); + expect(result.sentences, isEmpty); + expect(result.remainder, equals('No boundary here')); + }); + }); + + group('VoiceNotifier markdown stripping', () { + test('strips code fences', () { + expect(stripMarkdownForTts('Before\n```dart\ncode\n```\nAfter'), equals('Before After')); + }); + + test('strips bold and italic markers', () { + expect(stripMarkdownForTts('**bold** and *italic*'), equals('bold and italic')); + }); + + test('strips headers', () { + expect(stripMarkdownForTts('## Section title'), equals('Section title')); + }); + + test('keeps link text, removes URL', () { + expect(stripMarkdownForTts('[click here](https://example.com)'), equals('click here')); + }); + + test('strips list markers', () { + expect(stripMarkdownForTts('- item one\n- item two'), equals('item one item two')); + }); + }); +``` + +- [ ] **Step 2: Run test to confirm failures** + +```bash +flutter test test/widget_test.dart +``` + +Expected: FAIL — `voice_provider.dart` not found. + +- [ ] **Step 3: Create lib/providers/voice_provider.dart** + +```dart +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:record/record.dart'; + +import 'api_client_provider.dart'; + +// ── Public helpers (also used by tests) ────────────────────────────────────── + +class SentenceResult { + final List sentences; + final String remainder; + const SentenceResult({required this.sentences, required this.remainder}); +} + +/// Extract completed sentences from [text] at `.`, `!`, `?` boundaries. +/// Returns the completed sentences and the unconsumed remainder. +SentenceResult extractSentences(String text) { + final boundary = RegExp(r'[.!?]+(?=\s|$)'); + final sentences = []; + var remaining = text; + RegExpMatch? match; + while ((match = boundary.firstMatch(remaining)) != null) { + final end = match!.end; + final sentence = remaining.substring(0, end).trim(); + if (sentence.isNotEmpty) sentences.add(sentence); + remaining = remaining.substring(end).trimLeft(); + } + return SentenceResult(sentences: sentences, remainder: remaining); +} + +/// Strip markdown formatting before sending text to TTS. +String stripMarkdownForTts(String text) { + return text + .replaceAll(RegExp(r'```[\s\S]*?```'), '') // fenced code blocks + .replaceAll(RegExp(r'`([^`]+)`'), r'$1') // inline code — keep content + .replaceAll(RegExp(r'#{1,6}\s+'), '') // headings + .replaceAll(RegExp(r'\*\*([^*]+)\*\*'), r'$1') // bold + .replaceAll(RegExp(r'\*([^*]+)\*'), r'$1') // italic + .replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1') // links → text + .replaceAll(RegExp(r'^\s*[-*+]\s+', multiLine: true), '') // list markers + .replaceAll(RegExp(r'\n{2,}'), ' ') // multiple newlines → space + .replaceAll('\n', ' ') + .trim(); +} + +// ── State ───────────────────────────────────────────────────────────────────── + +enum VoiceMode { idle, recording, transcribing, playing } + +class VoiceState { + final VoiceMode mode; + final bool voiceModeActive; + final bool available; + + const VoiceState({ + this.mode = VoiceMode.idle, + this.voiceModeActive = false, + this.available = true, + }); + + VoiceState copyWith({ + VoiceMode? mode, + bool? voiceModeActive, + bool? available, + }) => + VoiceState( + mode: mode ?? this.mode, + voiceModeActive: voiceModeActive ?? this.voiceModeActive, + available: available ?? this.available, + ); +} + +// ── Provider ────────────────────────────────────────────────────────────────── + +final voiceProvider = + NotifierProvider(VoiceNotifier.new); + +// ── Notifier ────────────────────────────────────────────────────────────────── + +class VoiceNotifier extends Notifier { + // Audio I/O + AudioRecorder? _recorder; + AudioPlayer? _player; + StreamSubscription? _amplitudeSubscription; + + // Recording / silence detection + int _recordingStartMs = 0; + int _silenceMs = 0; + static const _silenceThresholdDb = -40.0; + static const _silenceDurationMs = 1500; + static const _minRecordingMs = 300; + String? _recordingPath; + + // Voice mode callbacks + Future Function(String transcript)? _onTranscript; + bool _enableTts = false; + + // Streaming TTS state + String _sentenceBuffer = ''; + int _lastSeenLength = 0; + bool _streamComplete = false; + + // TTS playback queue + final _ttsQueue = Queue(); + bool _ttsPlaying = false; + int _ttsCounter = 0; + Directory? _tempDir; + + @override + VoiceState build() { + _recorder = AudioRecorder(); + _player = AudioPlayer(); + ref.onDispose(() { + _amplitudeSubscription?.cancel(); + _recorder?.dispose(); + _player?.dispose(); + }); + return const VoiceState(); + } + + // ── Public API ────────────────────────────────────────────────────────────── + + /// Enter voice mode. Checks server availability and mic permission first. + /// [onTranscript] is called with the transcript when a recording completes. + /// [enableTts] — if true, TTS plays when [feedContent] is called. + Future enterVoiceMode({ + required Future Function(String transcript) onTranscript, + bool enableTts = false, + required void Function(String message) onError, + }) async { + if (state.voiceModeActive) { + exitVoiceMode(); + return; + } + + // Check server availability + try { + final status = + await ref.read(voiceRepositoryProvider).checkStatus(); + if (!status.fullyAvailable) { + onError('Voice not available on this server'); + return; + } + } catch (_) { + onError('Voice not available on this server'); + return; + } + + // Check microphone permission + final permStatus = await Permission.microphone.request(); + if (permStatus == PermissionStatus.denied || + permStatus == PermissionStatus.permanentlyDenied) { + onError('Microphone permission required'); + if (permStatus == PermissionStatus.permanentlyDenied) { + await openAppSettings(); + } + return; + } + + _onTranscript = onTranscript; + _enableTts = enableTts; + _tempDir = await getTemporaryDirectory(); + + state = state.copyWith(voiceModeActive: true, available: true); + await _startListening(); + } + + /// Exit voice mode, stop all recording and TTS. + void exitVoiceMode() { + _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + _recorder?.stop(); + _player?.stop(); + _ttsQueue.clear(); + _ttsPlaying = false; + _sentenceBuffer = ''; + _lastSeenLength = 0; + _streamComplete = false; + _onTranscript = null; + state = const VoiceState(); + } + + /// Feed streaming assistant content for TTS synthesis. + /// Call this from screens with each updated [fullContent] string. + /// Set [isComplete] to true when streaming has finished. + void feedContent(String fullContent, {required bool isComplete}) { + if (!state.voiceModeActive || !_enableTts) return; + + final delta = fullContent.length > _lastSeenLength + ? fullContent.substring(_lastSeenLength) + : ''; + _lastSeenLength = fullContent.length; + _sentenceBuffer += delta; + + _dispatchSentences(flush: isComplete); + + if (isComplete) { + _streamComplete = true; + _checkRestartListening(); + } + } + + // ── Internal recording ────────────────────────────────────────────────────── + + Future _startListening() async { + if (!state.voiceModeActive) return; + + _silenceMs = 0; + _recordingStartMs = DateTime.now().millisecondsSinceEpoch; + state = state.copyWith(mode: VoiceMode.recording); + + final dir = _tempDir ?? await getTemporaryDirectory(); + _recordingPath = '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.webm'; + + await _recorder!.start( + const RecordConfig(encoder: AudioEncoder.opus, sampleRate: 16000), + path: _recordingPath!, + ); + + _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder! + .onAmplitudeChanged(const Duration(milliseconds: 200)) + .listen(_onAmplitude); + } + + void _onAmplitude(Amplitude event) { + if (!state.voiceModeActive) return; + + final elapsed = + DateTime.now().millisecondsSinceEpoch - _recordingStartMs; + if (elapsed < _minRecordingMs) return; + + if (event.current < _silenceThresholdDb) { + _silenceMs += 200; + if (_silenceMs >= _silenceDurationMs) { + _amplitudeSubscription?.cancel(); + _handleSilence(); + } + } else { + _silenceMs = 0; + } + } + + Future _handleSilence() async { + if (!state.voiceModeActive) return; + state = state.copyWith(mode: VoiceMode.transcribing); + + final path = await _recorder!.stop(); + if (path == null || !state.voiceModeActive) return; + + try { + final bytes = await File(path).readAsBytes(); + await File(path).delete().catchError((_) {}); + + if (!state.voiceModeActive) return; + + final transcript = + await ref.read(voiceRepositoryProvider).transcribe(bytes); + + if (!state.voiceModeActive) return; + + if (transcript.isEmpty) { + // Empty transcript — restart silently + await _startListening(); + return; + } + + // Reset TTS state for this new turn + _sentenceBuffer = ''; + _lastSeenLength = 0; + _streamComplete = false; + + if (_enableTts) { + state = state.copyWith(mode: VoiceMode.playing); + } + + await _onTranscript?.call(transcript); + + // If TTS is not enabled, loop immediately + if (!_enableTts && state.voiceModeActive) { + await _startListening(); + } + } catch (_) { + // Network/API error — exit voice mode + exitVoiceMode(); + } + } + + // ── Internal TTS ──────────────────────────────────────────────────────────── + + void _dispatchSentences({required bool flush}) { + final result = extractSentences(_sentenceBuffer); + _sentenceBuffer = flush ? '' : result.remainder; + + for (final sentence in result.sentences) { + _enqueueSentence(sentence); + } + if (flush && result.remainder.trim().length >= 3) { + _enqueueSentence(result.remainder.trim()); + } + } + + void _enqueueSentence(String sentence) { + final stripped = stripMarkdownForTts(sentence); + if (stripped.length < 3) return; + // Fire-and-forget synthesis into queue + _synthesiseSentence(stripped); + } + + Future _synthesiseSentence(String text) async { + try { + final wavBytes = + await ref.read(voiceRepositoryProvider).synthesise(text); + if (!state.voiceModeActive) return; + _ttsQueue.add(wavBytes); + if (!_ttsPlaying) _drainTtsQueue(); + } catch (_) { + // Skip failed sentence — TTS errors are non-fatal + } + } + + Future _drainTtsQueue() async { + if (_ttsPlaying) return; + _ttsPlaying = true; + final dir = _tempDir ?? await getTemporaryDirectory(); + + try { + while (_ttsQueue.isNotEmpty && state.voiceModeActive) { + final wavBytes = _ttsQueue.removeFirst(); + final path = '${dir.path}/tts_${_ttsCounter++}.wav'; + final file = File(path); + await file.writeAsBytes(wavBytes); + + try { + await _player!.setFilePath(path); + await _player!.play(); + await _player!.processingStateStream.firstWhere( + (s) => s == ProcessingState.completed || s == ProcessingState.idle, + ); + } finally { + await file.delete().catchError((_) {}); + } + } + } finally { + _ttsPlaying = false; + } + + _checkRestartListening(); + } + + void _checkRestartListening() { + if (_streamComplete && + _ttsQueue.isEmpty && + !_ttsPlaying && + state.voiceModeActive) { + _streamComplete = false; + _lastSeenLength = 0; + _sentenceBuffer = ''; + _startListening(); + } + } +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +flutter test test/widget_test.dart +``` + +Expected: all tests pass. + +- [ ] **Step 5: Verify no analyzer errors** + +```bash +flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/providers/voice_provider.dart test/widget_test.dart +git commit -m "feat(voice): add VoiceNotifier with recording, silence detection, and streaming TTS" +``` + +--- + +## Task 5: VoiceMicButton widget + +**Files:** +- Create: `lib/widgets/voice_mic_button.dart` + +- [ ] **Step 1: Create lib/widgets/voice_mic_button.dart** + +```dart +import 'package:flutter/material.dart'; + +import '../providers/voice_provider.dart'; + +/// Animated mic button that reflects the current [VoiceMode]. +/// +/// - idle: muted background, mic_none icon +/// - recording: red with pulsing shadow ring +/// - transcribing: indigo with spinner +/// - playing: indigo with volume_up icon +class VoiceMicButton extends StatefulWidget { + final VoiceMode mode; + final bool voiceModeActive; + final VoidCallback? onTap; + + const VoiceMicButton({ + super.key, + required this.mode, + required this.voiceModeActive, + this.onTap, + }); + + @override + State createState() => _VoiceMicButtonState(); +} + +class _VoiceMicButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 1.0, end: 1.25).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + Color _bgColor(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return switch (widget.mode) { + VoiceMode.recording => const Color(0xFFEF4444), + VoiceMode.transcribing || VoiceMode.playing => cs.primary, + VoiceMode.idle => cs.surfaceContainerHighest, + }; + } + + Widget _icon(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final iconColor = widget.mode == VoiceMode.idle + ? cs.onSurfaceVariant + : Colors.white; + + return switch (widget.mode) { + VoiceMode.transcribing => SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: iconColor, + ), + ), + VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20), + _ => Icon(Icons.mic, color: iconColor, size: 20), + }; + } + + @override + Widget build(BuildContext context) { + final isRecording = widget.mode == VoiceMode.recording; + + final button = Material( + color: _bgColor(context), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: widget.onTap, + child: SizedBox( + width: 40, + height: 40, + child: Center(child: _icon(context)), + ), + ), + ); + + if (!isRecording) return button; + + return AnimatedBuilder( + animation: _pulseAnimation, + builder: (_, child) => Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFFEF4444).withValues(alpha: 0.35), + blurRadius: 8 * _pulseAnimation.value, + spreadRadius: 2 * _pulseAnimation.value, + ), + ], + ), + child: child, + ), + child: button, + ); + } +} +``` + +- [ ] **Step 2: Verify no errors** + +```bash +flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add lib/widgets/voice_mic_button.dart +git commit -m "feat(voice): add VoiceMicButton widget with animated states" +``` + +--- + +## Task 6: Chat screen integration + +**Files:** +- Modify: `lib/screens/chat/chat_screen.dart` + +The Chat screen needs: +1. A `VoiceMicButton` next to the send button +2. A red banner above the input row when voice mode is active +3. A `ref.listen` on `messagesProvider` that feeds content to `VoiceNotifier` during TTS + +- [ ] **Step 1: Add voice imports and mic button to chat_screen.dart** + +Replace the full contents of `lib/screens/chat/chat_screen.dart` with: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/exceptions.dart'; +import '../../data/models/message.dart'; +import '../../providers/chat_provider.dart'; +import '../../providers/voice_provider.dart'; +import '../../widgets/chat_message_bubble.dart'; +import '../../widgets/voice_mic_button.dart'; + +class ChatScreen extends ConsumerStatefulWidget { + final int conversationId; + const ChatScreen({super.key, required this.conversationId}); + + @override + ConsumerState createState() => _ChatScreenState(); +} + +class _ChatScreenState extends ConsumerState { + final _controller = TextEditingController(); + final _scrollController = ScrollController(); + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + // Exit voice mode if the user navigates away mid-session. + ref.read(voiceProvider.notifier).exitVoiceMode(); + super.dispose(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + + Future _send() async { + final text = _controller.text.trim(); + if (text.isEmpty) return; + _controller.clear(); + try { + await ref + .read(messagesProvider(widget.conversationId).notifier) + .sendMessage(text); + } 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 send message.')), + ); + } + } + } + + Future _toggleVoiceMode() async { + final voice = ref.read(voiceProvider); + if (voice.voiceModeActive) { + ref.read(voiceProvider.notifier).exitVoiceMode(); + return; + } + await ref.read(voiceProvider.notifier).enterVoiceMode( + onTranscript: (transcript) async { + await ref + .read(messagesProvider(widget.conversationId).notifier) + .sendMessage(transcript); + }, + enableTts: true, + onError: (msg) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + final messagesAsync = ref.watch(messagesProvider(widget.conversationId)); + final isStreaming = ref.watch(isStreamingProvider(widget.conversationId)); + final voiceState = ref.watch(voiceProvider); + + // Scroll when messages change. + ref.listen(messagesProvider(widget.conversationId), (_, _) { + _scrollToBottom(); + }); + + // Feed streaming content to VoiceNotifier for TTS. + ref.listen(messagesProvider(widget.conversationId), (_, next) { + if (!voiceState.voiceModeActive) return; + final messages = next.value; + if (messages == null || messages.isEmpty) return; + final last = messages.last; + if (last.role != MessageRole.assistant) return; + final isComplete = last.status != 'generating'; + ref + .read(voiceProvider.notifier) + .feedContent(last.content, isComplete: isComplete); + }); + + final convTitle = ref + .watch(conversationsProvider) + .value + ?.where((c) => c.id == widget.conversationId) + .firstOrNull + ?.title; + + return Scaffold( + appBar: AppBar( + title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'), + ), + body: Column( + children: [ + Expanded( + child: messagesAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (_, _) => const Center( + child: Text('Could not load messages.'), + ), + data: (messages) { + if (messages.isEmpty) { + return const Center( + child: Text('Send a message to start.')); + } + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 12), + itemCount: messages.length, + itemBuilder: (context, i) => + ChatMessageBubble(message: messages[i]), + ); + }, + ), + ), + // Voice mode banner + if (voiceState.voiceModeActive) + Container( + width: double.infinity, + color: const Color(0xFFEF4444).withValues(alpha: 0.12), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: const Text( + '🎤 Listening… tap mic to exit voice mode', + style: TextStyle( + fontSize: 12, + color: Color(0xFFF87171), + ), + ), + ), + const Divider(height: 1), + SafeArea( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + hintText: voiceState.voiceModeActive + ? 'Listening…' + : 'Message…', + hintStyle: voiceState.voiceModeActive + ? const TextStyle(fontStyle: FontStyle.italic) + : null, + border: const OutlineInputBorder(), + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + maxLines: + MediaQuery.of(context).size.width >= 600 ? 2 : 4, + minLines: 1, + textInputAction: TextInputAction.newline, + enabled: !isStreaming && !voiceState.voiceModeActive, + ), + ), + const SizedBox(width: 8), + VoiceMicButton( + mode: voiceState.mode, + voiceModeActive: voiceState.voiceModeActive, + onTap: _toggleVoiceMode, + ), + const SizedBox(width: 6), + IconButton.filled( + onPressed: (isStreaming || voiceState.voiceModeActive) + ? null + : _send, + icon: isStreaming + ? const SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + ), + ], + ), + ), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 2: Verify no errors** + +```bash +flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add lib/screens/chat/chat_screen.dart +git commit -m "feat(voice): integrate voice mode into Chat screen" +``` + +--- + +## Task 7: Quick Capture bar integration + +**Files:** +- Modify: `lib/app.dart` + +The `_QuickCaptureBarState` needs a `VoiceMicButton`. Recording is one-shot: transcript goes to `captureWorkQueueProvider.enqueue()`, no TTS, no loop. The mic button sits between the text field and the settings gear icon. + +- [ ] **Step 1: Add imports to lib/app.dart** + +At the top of `lib/app.dart`, add after the existing widget imports: + +```dart +import 'providers/voice_provider.dart'; +import 'widgets/voice_mic_button.dart'; +``` + +- [ ] **Step 2: Add _toggleCaptureMic method to _QuickCaptureBarState** + +In `_QuickCaptureBarState`, add this method after `_drainOfflineQueue`: + +```dart + Future _toggleCaptureMic() async { + final voice = ref.read(voiceProvider); + if (voice.voiceModeActive) { + ref.read(voiceProvider.notifier).exitVoiceMode(); + return; + } + await ref.read(voiceProvider.notifier).enterVoiceMode( + onTranscript: (transcript) async { + ref.read(captureWorkQueueProvider.notifier).enqueue(transcript); + }, + enableTts: false, + onError: (msg) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + }, + ); + } +``` + +- [ ] **Step 3: Add VoiceMicButton to the capture bar Row** + +In `_QuickCaptureBarState.build()`, locate the `Row` inside the `Padding` widget. The existing row has: `[Expanded(TextField), IconButton(settings)]`. + +Replace that `Row`'s `children` list with: + +```dart + children: [ + Expanded( + child: TextField( + controller: _controller, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _submit(), + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: ref.watch(voiceProvider).voiceModeActive + ? 'Listening…' + : _hintForLocation(location), + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + prefixIcon: totalPending > 0 + ? Badge( + label: Text('$totalPending'), + child: const Icon(Icons.cloud_upload_outlined), + ) + : isWorking + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2), + ), + ) + : const Icon(Icons.auto_awesome_outlined), + suffixIcon: _controller.text.trim().isNotEmpty + ? IconButton( + icon: const Icon(Icons.send), + onPressed: _submit, + tooltip: 'Capture', + ) + : null, + ), + ), + ), + VoiceMicButton( + mode: ref.watch(voiceProvider).mode, + voiceModeActive: ref.watch(voiceProvider).voiceModeActive, + onTap: _toggleCaptureMic, + ), + IconButton( + icon: const Icon(Icons.settings_outlined), + tooltip: 'Settings', + onPressed: () => context.push(Routes.settings), + ), + ], +``` + +- [ ] **Step 4: Verify no errors** + +```bash +flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add lib/app.dart +git commit -m "feat(voice): integrate one-shot voice capture into Quick Capture bar" +``` + +--- + +## Task 8: Briefing screen integration + +**Files:** +- Modify: `lib/screens/briefing/briefing_screen.dart` + +Same pattern as Chat: mic button in the reply row, voice banner above input, and `ref.listen` on `briefingProvider` to feed TTS. + +- [ ] **Step 1: Add imports to briefing_screen.dart** + +At the top of `lib/screens/briefing/briefing_screen.dart`, add after existing imports: + +```dart +import '../../data/models/message.dart'; +import '../../providers/voice_provider.dart'; +import '../../widgets/voice_mic_button.dart'; +``` + +- [ ] **Step 2: Add exitVoiceMode to dispose** + +In `_BriefingScreenState.dispose()`, add before `super.dispose()`: + +```dart + ref.read(voiceProvider.notifier).exitVoiceMode(); +``` + +- [ ] **Step 3: Add _toggleVoiceMode method** + +In `_BriefingScreenState`, add after `_refresh`: + +```dart + Future _toggleVoiceMode() async { + final voice = ref.read(voiceProvider); + if (voice.voiceModeActive) { + ref.read(voiceProvider.notifier).exitVoiceMode(); + return; + } + await ref.read(voiceProvider.notifier).enterVoiceMode( + onTranscript: (transcript) async { + await ref.read(briefingProvider.notifier).sendReply(transcript); + }, + enableTts: true, + onError: (msg) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + }, + ); + } +``` + +- [ ] **Step 4: Add TTS feed listener in build** + +In `_BriefingScreenState.build()`, locate the existing `ref.listen(briefingProvider, ...)` call that scrolls to bottom. Add a second `ref.listen` immediately after it: + +```dart + // Feed streaming assistant content to VoiceNotifier for TTS. + ref.listen(briefingProvider, (_, next) { + final voiceState = ref.read(voiceProvider); + if (!voiceState.voiceModeActive) return; + final conv = next.value; + if (conv == null || conv.messages.isEmpty) return; + final last = conv.messages.last; + if (last.role != MessageRole.assistant) return; + final isComplete = last.status != 'generating'; + ref + .read(voiceProvider.notifier) + .feedContent(last.content, isComplete: isComplete); + }); +``` + +- [ ] **Step 5: Add voice banner and mic button to the reply row** + +Locate the reply bar section in `build()` — the `Column` inside `data: (conv)`. It currently ends with: + +```dart + // Reply bar + const Divider(height: 1), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + Expanded( + child: TextField( ... ), + ), + const SizedBox(width: 8), + _GradientSendButton( + onPressed: isStreaming ? null : _sendReply, + isStreaming: isStreaming, + ), +``` + +Replace that section (from `// Reply bar` to the closing of its Column entry) with: + +```dart + // Voice mode banner + if (voiceState.voiceModeActive) + Container( + width: double.infinity, + color: const Color(0xFFEF4444).withValues(alpha: 0.12), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + child: const Text( + '🎤 Listening… tap mic to exit voice mode', + style: TextStyle( + fontSize: 12, + color: Color(0xFFF87171), + ), + ), + ), + // Reply bar + const Divider(height: 1), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + hintText: voiceState.voiceModeActive + ? 'Listening…' + : 'Reply to your briefing…', + hintStyle: voiceState.voiceModeActive + ? const TextStyle(fontStyle: FontStyle.italic) + : null, + border: const OutlineInputBorder(), + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + minLines: 1, + maxLines: 4, + textInputAction: TextInputAction.newline, + enabled: !isStreaming && !voiceState.voiceModeActive, + ), + ), + const SizedBox(width: 8), + VoiceMicButton( + mode: voiceState.mode, + voiceModeActive: voiceState.voiceModeActive, + onTap: _toggleVoiceMode, + ), + const SizedBox(width: 6), + _GradientSendButton( + onPressed: (isStreaming || voiceState.voiceModeActive) + ? null + : _sendReply, + isStreaming: isStreaming, + ), +``` + +- [ ] **Step 6: Add voiceState variable in build** + +At the top of `_BriefingScreenState.build()`, just after the existing `final isStreaming = ...` line, add: + +```dart + final voiceState = ref.watch(voiceProvider); +``` + +- [ ] **Step 7: Verify no errors** + +```bash +flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add lib/screens/briefing/briefing_screen.dart +git commit -m "feat(voice): integrate voice mode into Briefing screen" +``` + +--- + +## Task 9: Final verification + +- [ ] **Step 1: Run all tests** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +flutter test test/widget_test.dart +``` + +Expected: all tests pass. + +- [ ] **Step 2: Full static analysis** + +```bash +flutter analyze +``` + +Expected: no errors or warnings related to voice code. + +- [ ] **Step 3: Build debug APK to confirm it compiles** + +```bash +flutter build apk --debug 2>&1 | tail -20 +``` + +Expected: `✓ Built build/app/outputs/flutter-apk/app-debug.apk` + +- [ ] **Step 4: Push to dev** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabled_app +git push origin dev +``` + +--- + +## Manual smoke test checklist + +After installing the debug APK on device: + +- [ ] Chat screen: tap mic → permission dialog appears on first use +- [ ] Chat screen: speak → silence → transcript appears and message sends +- [ ] Chat screen: assistant response audio plays sentence by sentence +- [ ] Chat screen: tap mic again → voice mode exits, input re-enables +- [ ] Chat screen: navigate away mid-session → no audio or recording continues +- [ ] Quick Capture: tap mic → speak → transcript captured as note/task, mic returns to idle +- [ ] Briefing screen: tap mic → speak → reply sent, TTS plays response +- [ ] Server with voice disabled: tap mic → snackbar "Voice not available on this server" +- [ ] Deny mic permission: snackbar "Microphone permission required" From e891e8ba52d8f8dae758d1eb3a6eaa4f9f073cf5 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:39:35 -0400 Subject: [PATCH 16/24] feat(voice): add record + just_audio dependencies, RECORD_AUDIO permission --- android/app/src/main/AndroidManifest.xml | 1 + pubspec.lock | 120 +++++++++++++++++++++++ pubspec.yaml | 2 + 3 files changed, 123 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 196122d..1d9d0e9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + Date: Sun, 5 Apr 2026 14:40:19 -0400 Subject: [PATCH 17/24] feat(voice): add VoiceApi and VoiceStatus model --- lib/data/api/voice_api.dart | 81 +++++++++++++++++++++++++++++++++++++ test/widget_test.dart | 32 +++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 lib/data/api/voice_api.dart diff --git a/lib/data/api/voice_api.dart b/lib/data/api/voice_api.dart new file mode 100644 index 0000000..42e3dad --- /dev/null +++ b/lib/data/api/voice_api.dart @@ -0,0 +1,81 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +import 'api_client.dart'; + +class VoiceStatus { + final bool enabled; + final bool stt; + final bool tts; + + const VoiceStatus({ + required this.enabled, + required this.stt, + required this.tts, + }); + + /// True only when voice is enabled AND both STT and TTS are ready. + bool get fullyAvailable => enabled && stt && tts; + + factory VoiceStatus.fromJson(Map json) => VoiceStatus( + enabled: json['enabled'] as bool? ?? false, + stt: json['stt'] as bool? ?? false, + tts: json['tts'] as bool? ?? false, + ); +} + +class VoiceApi { + final Dio _dio; + const VoiceApi(this._dio); + + /// Check whether voice features are available on this server. + Future checkStatus() async { + try { + final response = await _dio.get('/api/voice/status'); + return VoiceStatus.fromJson(response.data as Map); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST WebM/Opus audio bytes and return the transcript string. + /// Returns empty string on empty or error response. + Future transcribe(Uint8List audioBytes) async { + try { + final formData = FormData.fromMap({ + 'audio': MultipartFile.fromBytes( + audioBytes, + filename: 'audio.webm', + contentType: DioMediaType('audio', 'webm'), + ), + }); + final response = await _dio.post( + '/api/voice/transcribe', + data: formData, + options: Options( + receiveTimeout: const Duration(seconds: 60), + contentType: 'multipart/form-data', + ), + ); + final data = response.data as Map; + return (data['transcript'] as String? ?? '').trim(); + } on DioException catch (e) { + throw dioToApp(e); + } + } + + /// POST text and return raw WAV bytes. + Future synthesise(String text) async { + try { + final response = await _dio.post( + '/api/voice/synthesise', + data: {'text': text}, + options: Options(responseType: ResponseType.bytes), + ); + return Uint8List.fromList(response.data as List); + } on DioException catch (e) { + throw dioToApp(e); + } + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 531745d..bdfc282 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:fabled_app/data/api/voice_api.dart'; import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/note.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -66,4 +67,35 @@ void main() { expect(item.status, isNull); }); }); + + group('VoiceStatus.fromJson', () { + test('parses enabled with stt and tts available', () { + final status = VoiceStatus.fromJson({ + 'enabled': true, + 'stt': true, + 'tts': true, + }); + expect(status.enabled, isTrue); + expect(status.stt, isTrue); + expect(status.tts, isTrue); + }); + + test('parses disabled state', () { + final status = VoiceStatus.fromJson({ + 'enabled': false, + 'stt': false, + 'tts': false, + }); + expect(status.enabled, isFalse); + }); + + test('fullyAvailable is false when enabled but stt is false', () { + final status = VoiceStatus.fromJson({ + 'enabled': true, + 'stt': false, + 'tts': true, + }); + expect(status.fullyAvailable, isFalse); + }); + }); } From 6e33d74178560a8b9282818fc634042f82a1bf92 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:40:49 -0400 Subject: [PATCH 18/24] feat(voice): add VoiceRepository and provider registration --- lib/data/repositories/voice_repository.dart | 12 ++++++++++++ lib/providers/api_client_provider.dart | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 lib/data/repositories/voice_repository.dart diff --git a/lib/data/repositories/voice_repository.dart b/lib/data/repositories/voice_repository.dart new file mode 100644 index 0000000..7eb9417 --- /dev/null +++ b/lib/data/repositories/voice_repository.dart @@ -0,0 +1,12 @@ +import 'dart:typed_data'; + +import '../api/voice_api.dart'; + +class VoiceRepository { + final VoiceApi _api; + const VoiceRepository(this._api); + + Future checkStatus() => _api.checkStatus(); + Future transcribe(Uint8List audioBytes) => _api.transcribe(audioBytes); + Future synthesise(String text) => _api.synthesise(text); +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 7ae55e2..e9b2348 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -7,6 +7,7 @@ import '../data/api/auth_api.dart'; import '../data/api/briefing_api.dart'; 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/notes_api.dart'; import '../data/api/projects_api.dart'; @@ -16,6 +17,7 @@ import '../data/api/tasks_api.dart'; import '../data/repositories/auth_repository.dart'; import '../data/repositories/chat_repository.dart'; import '../data/repositories/knowledge_repository.dart'; +import '../data/repositories/voice_repository.dart'; import '../data/repositories/milestones_repository.dart'; import '../data/repositories/notes_repository.dart'; import '../data/repositories/projects_repository.dart'; @@ -100,3 +102,11 @@ final briefingApiProvider = Provider((ref) { final settingsApiProvider = Provider((ref) { return SettingsApi(ref.watch(dioProvider)); }); + +final voiceApiProvider = Provider((ref) { + return VoiceApi(ref.watch(dioProvider)); +}); + +final voiceRepositoryProvider = Provider((ref) { + return VoiceRepository(ref.watch(voiceApiProvider)); +}); From 2cb566336be65944cfd4af10e397c90bbcd03266 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:44:21 -0400 Subject: [PATCH 19/24] feat(voice): add VoiceNotifier with recording, silence detection, and streaming TTS Co-Authored-By: Claude Sonnet 4.6 --- lib/providers/voice_provider.dart | 371 ++++++++++++++++++++++++++++++ test/widget_test.dart | 48 ++++ 2 files changed, 419 insertions(+) create mode 100644 lib/providers/voice_provider.dart diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart new file mode 100644 index 0000000..5f1002a --- /dev/null +++ b/lib/providers/voice_provider.dart @@ -0,0 +1,371 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:record/record.dart'; + +import 'api_client_provider.dart'; + +// ── Public helpers (also used by tests) ────────────────────────────────────── + +class SentenceResult { + final List sentences; + final String remainder; + const SentenceResult({required this.sentences, required this.remainder}); +} + +/// Extract completed sentences from [text] at `.`, `!`, `?` boundaries. +/// Returns the completed sentences and the unconsumed remainder. +SentenceResult extractSentences(String text) { + final boundary = RegExp(r'[.!?]+(?=\s|$)'); + final sentences = []; + var remaining = text; + RegExpMatch? match; + while ((match = boundary.firstMatch(remaining)) != null) { + final end = match!.end; + final sentence = remaining.substring(0, end).trim(); + if (sentence.isNotEmpty) sentences.add(sentence); + remaining = remaining.substring(end).trimLeft(); + } + return SentenceResult(sentences: sentences, remainder: remaining); +} + +/// Strip markdown formatting before sending text to TTS. +String stripMarkdownForTts(String text) { + return text + .replaceAll(RegExp(r'```[\s\S]*?```'), '') // fenced code blocks + .replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m[1]!) // inline code + .replaceAll(RegExp(r'#{1,6}\s+'), '') // headings + .replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m[1]!) // bold + .replaceAllMapped(RegExp(r'\*([^*]+)\*'), (m) => m[1]!) // italic + .replaceAllMapped( + RegExp(r'\[([^\]]+)\]\([^)]+\)'), (m) => m[1]!) // links → text + .replaceAll(RegExp(r'^\s*[-*+]\s+', multiLine: true), '') // list markers + .replaceAll(RegExp(r'\n{2,}'), ' ') // multiple newlines → space + .replaceAll('\n', ' ') + .trim(); +} + +// ── State ───────────────────────────────────────────────────────────────────── + +enum VoiceMode { idle, recording, transcribing, playing } + +class VoiceState { + final VoiceMode mode; + final bool voiceModeActive; + final bool available; + + const VoiceState({ + this.mode = VoiceMode.idle, + this.voiceModeActive = false, + this.available = true, + }); + + VoiceState copyWith({ + VoiceMode? mode, + bool? voiceModeActive, + bool? available, + }) => + VoiceState( + mode: mode ?? this.mode, + voiceModeActive: voiceModeActive ?? this.voiceModeActive, + available: available ?? this.available, + ); +} + +// ── Provider ────────────────────────────────────────────────────────────────── + +final voiceProvider = + NotifierProvider(VoiceNotifier.new); + +// ── Notifier ────────────────────────────────────────────────────────────────── + +class VoiceNotifier extends Notifier { + // Audio I/O + AudioRecorder? _recorder; + AudioPlayer? _player; + StreamSubscription? _amplitudeSubscription; + + // Recording / silence detection + int _recordingStartMs = 0; + int _silenceMs = 0; + static const _silenceThresholdDb = -40.0; + static const _silenceDurationMs = 1500; + static const _minRecordingMs = 300; + + // Voice mode callbacks + Future Function(String transcript)? _onTranscript; + bool _enableTts = false; + + // Streaming TTS state + String _sentenceBuffer = ''; + int _lastSeenLength = 0; + bool _streamComplete = false; + + // TTS playback queue + final _ttsQueue = Queue(); + bool _ttsPlaying = false; + int _ttsCounter = 0; + Directory? _tempDir; + + @override + VoiceState build() { + _recorder = AudioRecorder(); + _player = AudioPlayer(); + ref.onDispose(() { + _amplitudeSubscription?.cancel(); + _recorder?.dispose(); + _player?.dispose(); + }); + return const VoiceState(); + } + + // ── Public API ────────────────────────────────────────────────────────────── + + /// Enter voice mode. Checks server availability and mic permission first. + /// [onTranscript] is called with the transcript when a recording completes. + /// [enableTts] — if true, TTS plays when [feedContent] is called. + /// [onError] — called with a human-readable message on failure. + Future enterVoiceMode({ + required Future Function(String transcript) onTranscript, + bool enableTts = false, + required void Function(String message) onError, + }) async { + if (state.voiceModeActive) { + exitVoiceMode(); + return; + } + + // Check server availability + try { + final status = await ref.read(voiceRepositoryProvider).checkStatus(); + if (!status.fullyAvailable) { + onError('Voice not available on this server'); + return; + } + } catch (_) { + onError('Voice not available on this server'); + return; + } + + // Check microphone permission + final permStatus = await Permission.microphone.request(); + if (permStatus == PermissionStatus.denied || + permStatus == PermissionStatus.permanentlyDenied) { + onError('Microphone permission required'); + if (permStatus == PermissionStatus.permanentlyDenied) { + await openAppSettings(); + } + return; + } + + _onTranscript = onTranscript; + _enableTts = enableTts; + _tempDir = await getTemporaryDirectory(); + + state = state.copyWith(voiceModeActive: true, available: true); + await _startListening(); + } + + /// Exit voice mode, stop all recording and TTS. + void exitVoiceMode() { + _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + _recorder?.stop(); + _player?.stop(); + _ttsQueue.clear(); + _ttsPlaying = false; + _sentenceBuffer = ''; + _lastSeenLength = 0; + _streamComplete = false; + _onTranscript = null; + state = const VoiceState(); + } + + /// Feed streaming assistant content for TTS synthesis. + /// Call from screens with the full [fullContent] string on each update. + /// Set [isComplete] to true when the stream has finished. + void feedContent(String fullContent, {required bool isComplete}) { + if (!state.voiceModeActive || !_enableTts) return; + + final delta = fullContent.length > _lastSeenLength + ? fullContent.substring(_lastSeenLength) + : ''; + _lastSeenLength = fullContent.length; + _sentenceBuffer += delta; + + _dispatchSentences(flush: isComplete); + + if (isComplete) { + _streamComplete = true; + _checkRestartListening(); + } + } + + // ── Internal recording ────────────────────────────────────────────────────── + + Future _startListening() async { + if (!state.voiceModeActive) return; + + _silenceMs = 0; + _recordingStartMs = DateTime.now().millisecondsSinceEpoch; + state = state.copyWith(mode: VoiceMode.recording); + + final dir = _tempDir ?? await getTemporaryDirectory(); + final path = + '${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.webm'; + + await _recorder!.start( + const RecordConfig(encoder: AudioEncoder.opus, sampleRate: 16000), + path: path, + ); + + _amplitudeSubscription?.cancel(); + _amplitudeSubscription = _recorder! + .onAmplitudeChanged(const Duration(milliseconds: 200)) + .listen(_onAmplitude); + } + + void _onAmplitude(Amplitude event) { + if (!state.voiceModeActive) return; + + final elapsed = + DateTime.now().millisecondsSinceEpoch - _recordingStartMs; + if (elapsed < _minRecordingMs) return; + + if (event.current < _silenceThresholdDb) { + _silenceMs += 200; + if (_silenceMs >= _silenceDurationMs) { + _amplitudeSubscription?.cancel(); + _amplitudeSubscription = null; + _handleSilence(); + } + } else { + _silenceMs = 0; + } + } + + Future _handleSilence() async { + if (!state.voiceModeActive) return; + state = state.copyWith(mode: VoiceMode.transcribing); + + final path = await _recorder!.stop(); + if (path == null || !state.voiceModeActive) return; + + try { + final bytes = await File(path).readAsBytes(); + await File(path).delete().catchError((_) => File(path)); + + if (!state.voiceModeActive) return; + + final transcript = + await ref.read(voiceRepositoryProvider).transcribe(bytes); + + if (!state.voiceModeActive) return; + + if (transcript.isEmpty) { + // Empty transcript — restart silently + await _startListening(); + return; + } + + // Reset TTS state for this new turn + _sentenceBuffer = ''; + _lastSeenLength = 0; + _streamComplete = false; + + if (_enableTts) { + state = state.copyWith(mode: VoiceMode.playing); + } + + await _onTranscript?.call(transcript); + + // If TTS is not enabled, loop immediately + if (!_enableTts && state.voiceModeActive) { + await _startListening(); + } + } catch (_) { + // Network/API error — exit voice mode + exitVoiceMode(); + } + } + + // ── Internal TTS ──────────────────────────────────────────────────────────── + + void _dispatchSentences({required bool flush}) { + final result = extractSentences(_sentenceBuffer); + _sentenceBuffer = flush ? '' : result.remainder; + + for (final sentence in result.sentences) { + _enqueueSentence(sentence); + } + if (flush && result.remainder.trim().length >= 3) { + _enqueueSentence(result.remainder.trim()); + } + } + + void _enqueueSentence(String sentence) { + final stripped = stripMarkdownForTts(sentence); + if (stripped.length < 3) return; + _synthesiseSentence(stripped); + } + + Future _synthesiseSentence(String text) async { + try { + final wavBytes = + await ref.read(voiceRepositoryProvider).synthesise(text); + if (!state.voiceModeActive) return; + _ttsQueue.add(wavBytes); + if (!_ttsPlaying) _drainTtsQueue(); + } catch (_) { + // Skip failed sentence — TTS errors are non-fatal + } + } + + Future _drainTtsQueue() async { + if (_ttsPlaying) return; + _ttsPlaying = true; + final dir = _tempDir ?? await getTemporaryDirectory(); + + try { + while (_ttsQueue.isNotEmpty && state.voiceModeActive) { + final wavBytes = _ttsQueue.removeFirst(); + final path = '${dir.path}/tts_${_ttsCounter++}.wav'; + final file = File(path); + await file.writeAsBytes(wavBytes); + + try { + await _player!.setFilePath(path); + await _player!.play(); + await _player!.processingStateStream.firstWhere( + (s) => + s == ProcessingState.completed || s == ProcessingState.idle, + ); + } finally { + await file.delete().catchError((_) => file); + } + } + } finally { + _ttsPlaying = false; + } + + _checkRestartListening(); + } + + void _checkRestartListening() { + if (_streamComplete && + _ttsQueue.isEmpty && + !_ttsPlaying && + state.voiceModeActive) { + _streamComplete = false; + _lastSeenLength = 0; + _sentenceBuffer = ''; + _startListening(); + } + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index bdfc282..667fdd2 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,4 +1,5 @@ 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:flutter_test/flutter_test.dart'; @@ -98,4 +99,51 @@ void main() { expect(status.fullyAvailable, isFalse); }); }); + + group('VoiceNotifier sentence extraction', () { + test('extracts complete sentences at full stops', () { + final result = extractSentences('Hello world. How are you? I am fine!'); + expect(result.sentences, + equals(['Hello world.', 'How are you?', 'I am fine!'])); + expect(result.remainder, equals('')); + }); + + test('leaves incomplete fragment in remainder', () { + final result = extractSentences('Hello world. Incomplete'); + expect(result.sentences, equals(['Hello world.'])); + expect(result.remainder, equals('Incomplete')); + }); + + test('returns empty sentences and full text when no boundary', () { + final result = extractSentences('No boundary here'); + expect(result.sentences, isEmpty); + expect(result.remainder, equals('No boundary here')); + }); + }); + + group('VoiceNotifier markdown stripping', () { + test('strips code fences', () { + expect(stripMarkdownForTts('Before\n```dart\ncode\n```\nAfter'), + equals('Before After')); + }); + + test('strips bold and italic markers', () { + expect(stripMarkdownForTts('**bold** and *italic*'), + equals('bold and italic')); + }); + + test('strips headers', () { + expect(stripMarkdownForTts('## Section title'), equals('Section title')); + }); + + test('keeps link text, removes URL', () { + expect(stripMarkdownForTts('[click here](https://example.com)'), + equals('click here')); + }); + + test('strips list markers', () { + expect(stripMarkdownForTts('- item one\n- item two'), + equals('item one item two')); + }); + }); } From 81077349a5c8f28100edb1693cc2f6861163c389 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:44:55 -0400 Subject: [PATCH 20/24] feat(voice): add VoiceMicButton animated widget Co-Authored-By: Claude Sonnet 4.6 --- lib/widgets/voice_mic_button.dart | 117 ++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 lib/widgets/voice_mic_button.dart diff --git a/lib/widgets/voice_mic_button.dart b/lib/widgets/voice_mic_button.dart new file mode 100644 index 0000000..1476993 --- /dev/null +++ b/lib/widgets/voice_mic_button.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +import '../providers/voice_provider.dart'; + +/// Animated mic button that reflects the current [VoiceMode]. +/// +/// - idle: muted background, mic_none icon +/// - recording: red with pulsing shadow ring +/// - transcribing: indigo with spinner +/// - playing: indigo with volume_up icon +class VoiceMicButton extends StatefulWidget { + final VoiceMode mode; + final bool voiceModeActive; + final VoidCallback? onTap; + + const VoiceMicButton({ + super.key, + required this.mode, + required this.voiceModeActive, + this.onTap, + }); + + @override + State createState() => _VoiceMicButtonState(); +} + +class _VoiceMicButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 1.0, end: 1.25).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + Color _bgColor(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return switch (widget.mode) { + VoiceMode.recording => const Color(0xFFEF4444), + VoiceMode.transcribing || VoiceMode.playing => cs.primary, + VoiceMode.idle => cs.surfaceContainerHighest, + }; + } + + Widget _icon(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final iconColor = widget.mode == VoiceMode.idle + ? cs.onSurfaceVariant + : Colors.white; + + return switch (widget.mode) { + VoiceMode.transcribing => SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: iconColor, + ), + ), + VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20), + _ => Icon(Icons.mic, color: iconColor, size: 20), + }; + } + + @override + Widget build(BuildContext context) { + final isRecording = widget.mode == VoiceMode.recording; + + final button = Material( + color: _bgColor(context), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: widget.onTap, + child: SizedBox( + width: 40, + height: 40, + child: Center(child: _icon(context)), + ), + ), + ); + + if (!isRecording) return button; + + return AnimatedBuilder( + animation: _pulseAnimation, + builder: (_, child) => Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFFEF4444).withValues(alpha: 0.35), + blurRadius: 8 * _pulseAnimation.value, + spreadRadius: 2 * _pulseAnimation.value, + ), + ], + ), + child: child, + ), + child: button, + ); + } +} From 2231c60bfbb4d4789a97cbd98e86907cca733474 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:45:53 -0400 Subject: [PATCH 21/24] feat(voice): integrate voice mode into Chat screen Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/chat/chat_screen.dart | 88 +++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index 91efd5d..e526b31 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; +import '../../data/models/message.dart'; import '../../providers/chat_provider.dart'; +import '../../providers/voice_provider.dart'; import '../../widgets/chat_message_bubble.dart'; +import '../../widgets/voice_mic_button.dart'; class ChatScreen extends ConsumerStatefulWidget { final int conversationId; @@ -21,6 +24,8 @@ class _ChatScreenState extends ConsumerState { void dispose() { _controller.dispose(); _scrollController.dispose(); + // Exit voice mode if the user navigates away mid-session. + ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } @@ -58,16 +63,52 @@ class _ChatScreenState extends ConsumerState { } } + Future _toggleVoiceMode() async { + final voice = ref.read(voiceProvider); + if (voice.voiceModeActive) { + ref.read(voiceProvider.notifier).exitVoiceMode(); + return; + } + await ref.read(voiceProvider.notifier).enterVoiceMode( + onTranscript: (transcript) async { + await ref + .read(messagesProvider(widget.conversationId).notifier) + .sendMessage(transcript); + }, + enableTts: true, + onError: (msg) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + }, + ); + } + @override Widget build(BuildContext context) { final messagesAsync = ref.watch(messagesProvider(widget.conversationId)); final isStreaming = ref.watch(isStreamingProvider(widget.conversationId)); + final voiceState = ref.watch(voiceProvider); - // Scroll when messages change - ref.listen(messagesProvider(widget.conversationId), (_, _) { + // Scroll when messages change. + ref.listen(messagesProvider(widget.conversationId), (prev, next) { _scrollToBottom(); }); + // Feed streaming content to VoiceNotifier for TTS. + ref.listen(messagesProvider(widget.conversationId), (prev, next) { + if (!voiceState.voiceModeActive) return; + final messages = next.value; + if (messages == null || messages.isEmpty) return; + final last = messages.last; + if (last.role != MessageRole.assistant) return; + final isComplete = last.status != 'generating'; + ref + .read(voiceProvider.notifier) + .feedContent(last.content, isComplete: isComplete); + }); + final convTitle = ref .watch(conversationsProvider) .value @@ -85,7 +126,7 @@ class _ChatScreenState extends ConsumerState { child: messagesAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => const Center( + error: (err, stack) => const Center( child: Text('Could not load messages.'), ), data: (messages) { @@ -104,6 +145,21 @@ class _ChatScreenState extends ConsumerState { }, ), ), + // Voice mode banner + if (voiceState.voiceModeActive) + Container( + width: double.infinity, + color: const Color(0xFFEF4444).withValues(alpha: 0.12), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: const Text( + '🎤 Listening… tap mic to exit voice mode', + style: TextStyle( + fontSize: 12, + color: Color(0xFFF87171), + ), + ), + ), const Divider(height: 1), SafeArea( child: Padding( @@ -114,23 +170,36 @@ class _ChatScreenState extends ConsumerState { Expanded( child: TextField( controller: _controller, - decoration: const InputDecoration( - hintText: 'Message...', - border: OutlineInputBorder(), + decoration: InputDecoration( + hintText: voiceState.voiceModeActive + ? 'Listening…' + : 'Message…', + hintStyle: voiceState.voiceModeActive + ? const TextStyle(fontStyle: FontStyle.italic) + : null, + border: const OutlineInputBorder(), isDense: true, - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), ), maxLines: MediaQuery.of(context).size.width >= 600 ? 2 : 4, minLines: 1, textInputAction: TextInputAction.newline, - enabled: !isStreaming, + enabled: !isStreaming && !voiceState.voiceModeActive, ), ), const SizedBox(width: 8), + VoiceMicButton( + mode: voiceState.mode, + voiceModeActive: voiceState.voiceModeActive, + onTap: _toggleVoiceMode, + ), + const SizedBox(width: 6), IconButton.filled( - onPressed: isStreaming ? null : _send, + onPressed: (isStreaming || voiceState.voiceModeActive) + ? null + : _send, icon: isStreaming ? const SizedBox( width: 20, @@ -149,4 +218,3 @@ class _ChatScreenState extends ConsumerState { ); } } - From 211bf0d6583068108e7ab2a4404fa7b503680d32 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:46:34 -0400 Subject: [PATCH 22/24] feat(voice): integrate one-shot voice capture into Quick Capture bar Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/app.dart b/lib/app.dart index 009d840..8c7f297 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -29,6 +29,8 @@ import 'screens/settings/settings_screen.dart'; import 'screens/setup/setup_screen.dart'; import 'screens/splash/splash_screen.dart'; import 'screens/tasks/task_edit_screen.dart'; +import 'providers/voice_provider.dart'; +import 'widgets/voice_mic_button.dart'; // ChangeNotifier that fires when auth or server URL changes, // used as GoRouter.refreshListenable so the router re-evaluates redirects @@ -399,6 +401,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { @override void dispose() { _controller.dispose(); + ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } @@ -440,6 +443,25 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { } } + Future _toggleCaptureMic() async { + final voice = ref.read(voiceProvider); + if (voice.voiceModeActive) { + ref.read(voiceProvider.notifier).exitVoiceMode(); + return; + } + await ref.read(voiceProvider.notifier).enterVoiceMode( + onTranscript: (transcript) async { + ref.read(captureWorkQueueProvider.notifier).enqueue(transcript); + }, + enableTts: false, + onError: (msg) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + }, + ); + } + String _hintForLocation(String location) { if (location.startsWith(Routes.knowledge)) return 'Capture a note…'; if (location.startsWith(Routes.projects)) return 'Capture a note…'; @@ -482,7 +504,9 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { onSubmitted: (_) => _submit(), onChanged: (_) => setState(() {}), decoration: InputDecoration( - hintText: _hintForLocation(location), + hintText: ref.watch(voiceProvider).voiceModeActive + ? 'Listening…' + : _hintForLocation(location), isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10), @@ -512,6 +536,11 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { ), ), ), + VoiceMicButton( + mode: ref.watch(voiceProvider).mode, + voiceModeActive: ref.watch(voiceProvider).voiceModeActive, + onTap: _toggleCaptureMic, + ), IconButton( icon: const Icon(Icons.settings_outlined), tooltip: 'Settings', From 1b08b2fd9ea234bf10012ca000b2ba863f431fca Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:47:38 -0400 Subject: [PATCH 23/24] feat(voice): integrate voice mode into Briefing screen Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/briefing/briefing_screen.dart | 81 ++++++++++++++++++++--- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 36bcf3b..4ff546f 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -11,6 +11,8 @@ import '../../widgets/chat_message_bubble.dart'; import '../../widgets/weather_card.dart'; import '../../widgets/news_card.dart'; import 'briefing_history_screen.dart'; +import '../../providers/voice_provider.dart'; +import '../../widgets/voice_mic_button.dart'; class BriefingScreen extends ConsumerStatefulWidget { const BriefingScreen({super.key}); @@ -55,6 +57,7 @@ class _BriefingScreenState extends ConsumerState WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); + ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } @@ -106,6 +109,26 @@ class _BriefingScreenState extends ConsumerState } } + Future _toggleVoiceMode() async { + final voice = ref.read(voiceProvider); + if (voice.voiceModeActive) { + ref.read(voiceProvider.notifier).exitVoiceMode(); + return; + } + await ref.read(voiceProvider.notifier).enterVoiceMode( + onTranscript: (transcript) async { + await ref.read(briefingProvider.notifier).sendReply(transcript); + }, + enableTts: true, + onError: (msg) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + }, + ); + } + Future _refresh() async { setState(() => _refreshing = true); try { @@ -125,10 +148,24 @@ class _BriefingScreenState extends ConsumerState Widget build(BuildContext context) { final briefingAsync = ref.watch(briefingProvider); final isStreaming = ref.watch(isBriefingStreamingProvider); + final voiceState = ref.watch(voiceProvider); final scheme = Theme.of(context).colorScheme; // Scroll to bottom when messages change - ref.listen(briefingProvider, (_, _) => _scrollToBottom()); + ref.listen(briefingProvider, (prev, next) => _scrollToBottom()); + + // Feed streaming assistant content to VoiceNotifier for TTS. + ref.listen(briefingProvider, (prev, next) { + if (!voiceState.voiceModeActive) return; + final conv = next.value; + if (conv == null || conv.messages.isEmpty) return; + final last = conv.messages.last; + if (last.role != MessageRole.assistant) return; + final isComplete = last.status != 'generating'; + ref + .read(voiceProvider.notifier) + .feedContent(last.content, isComplete: isComplete); + }); return Scaffold( appBar: AppBar( @@ -179,7 +216,7 @@ class _BriefingScreenState extends ConsumerState ), body: briefingAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => Center( + error: (err, stack) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -248,6 +285,21 @@ class _BriefingScreenState extends ConsumerState color: scheme.primary, ), + // Voice mode banner + if (voiceState.voiceModeActive) + Container( + width: double.infinity, + color: const Color(0xFFEF4444).withValues(alpha: 0.12), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + child: const Text( + '🎤 Listening… tap mic to exit voice mode', + style: TextStyle( + fontSize: 12, + color: Color(0xFFF87171), + ), + ), + ), // Reply bar const Divider(height: 1), SafeArea( @@ -258,22 +310,35 @@ class _BriefingScreenState extends ConsumerState Expanded( child: TextField( controller: _controller, - decoration: const InputDecoration( - hintText: 'Reply to your briefing…', - border: OutlineInputBorder(), + decoration: InputDecoration( + hintText: voiceState.voiceModeActive + ? 'Listening…' + : 'Reply to your briefing…', + hintStyle: voiceState.voiceModeActive + ? const TextStyle(fontStyle: FontStyle.italic) + : null, + border: const OutlineInputBorder(), isDense: true, - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), ), minLines: 1, maxLines: 4, textInputAction: TextInputAction.newline, - enabled: !isStreaming, + enabled: !isStreaming && !voiceState.voiceModeActive, ), ), const SizedBox(width: 8), + VoiceMicButton( + mode: voiceState.mode, + voiceModeActive: voiceState.voiceModeActive, + onTap: _toggleVoiceMode, + ), + const SizedBox(width: 6), _GradientSendButton( - onPressed: isStreaming ? null : _sendReply, + onPressed: (isStreaming || voiceState.voiceModeActive) + ? null + : _sendReply, isStreaming: isStreaming, ), ], From 29ff9f821ab477f4695c1693e281e7d96cb3b2c5 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:50:05 -0400 Subject: [PATCH 24/24] fix: override record_linux to 1.3.0 for platform interface compatibility record_linux 0.7.2 doesn't implement startStream from record_platform_interface 1.5.0 Co-Authored-By: Claude Sonnet 4.6 --- pubspec.lock | 6 +++--- pubspec.yaml | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index e341968..44961eb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -817,13 +817,13 @@ packages: source: hosted version: "1.2.2" record_linux: - dependency: transitive + dependency: "direct overridden" description: name: record_linux - sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3" + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "1.3.0" record_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4af2f5e..064c763 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,9 @@ dependencies: record: ^5.0.0 just_audio: ^0.9.39 +dependency_overrides: + record_linux: ^1.3.0 + dev_dependencies: flutter_test: sdk: flutter