Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9944680c5b | |||
| 29ff9f821a | |||
| 1b08b2fd9e | |||
| 211bf0d658 | |||
| 2231c60bfb | |||
| 81077349a5 | |||
| 2cb566336b | |||
| 6e33d74178 | |||
| c90b0b3d48 | |||
| e891e8ba52 | |||
| 89fe8994fb | |||
| 3a3f44b00b | |||
| d97f7b0ebd | |||
| 2ab24a99a8 | |||
| e39d31fe43 | |||
| a50193dbc0 | |||
| f5ba2d25a3 | |||
| b5d9efa3ec | |||
| e0b56fc149 | |||
| 535833abfe | |||
| deec2318f7 | |||
| 2920252f13 | |||
| c8cdcbf230 | |||
| e89626a782 | |||
| ac4b2359a5 | |||
| f11e869a1b | |||
| 23509adfa8 | |||
| 24cef2e78c | |||
| 399da397da | |||
| 2bdd663719 | |||
| ba889a38be | |||
| 7fce19a37c | |||
| 9f524e158d | |||
| 46d0427901 | |||
| 45294ade31 | |||
| 89c31f1904 | |||
| 9f1d2317af | |||
| c3d9cc273f | |||
| 63e01389e8 | |||
| 5ca5856f15 | |||
| a337c3fda3 | |||
| fc1c7cade2 | |||
| 0971433c7a | |||
| 844f68d376 | |||
| 6af23fc853 |
@@ -54,6 +54,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Set up release signing
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
|
||||
STORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
run: |
|
||||
echo "$KEYSTORE_B64" | base64 -d > android/app/release.jks
|
||||
printf 'storePassword=%s\nkeyPassword=%s\nkeyAlias=%s\nstoreFile=release.jks\n' \
|
||||
"$STORE_PASSWORD" "$STORE_PASSWORD" "$KEY_ALIAS" > android/key.properties
|
||||
|
||||
- name: Build release APK
|
||||
run: |
|
||||
# Derive version from the tag (e.g. v26.03.12 → name=26.03.12 number=260312)
|
||||
|
||||
@@ -49,3 +49,4 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
.superpowers/
|
||||
|
||||
@@ -10,3 +10,4 @@ GeneratedPluginRegistrant.java
|
||||
# Signing secrets — never commit these
|
||||
key.properties
|
||||
fabled-release-key.jks
|
||||
app/release.jks
|
||||
|
||||
@@ -61,7 +61,7 @@ android {
|
||||
val variant = this
|
||||
outputs.all {
|
||||
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
output?.outputFileName = "Fabled-${variant.versionName}.${variant.versionCode}.apk"
|
||||
output?.outputFileName = "Fabled-${variant.versionCode}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
@@ -70,5 +71,14 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- url_launcher: open http/https links in browser -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 `<uses-permission android:name="android.permission.INTERNET" />` is present
|
||||
- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `android:usesCleartextTraffic="true"` is set on the `<application>` 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<String> 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<String, dynamic> 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<dynamic>?)?.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>, int)> fetchIds({
|
||||
String? noteType,
|
||||
List<String> tags = const [],
|
||||
String sort = 'modified',
|
||||
String? q,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final params = <String, dynamic>{
|
||||
'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<String, dynamic>;
|
||||
final ids = (data['ids'] as List<dynamic>).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<List<KnowledgeItem>> fetchBatch(List<int> ids) async {
|
||||
if (ids.isEmpty) return [];
|
||||
final response = await _dio.get(
|
||||
'/api/knowledge/batch',
|
||||
queryParameters: {'ids': ids.join(',')},
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return (data['items'] as List<dynamic>)
|
||||
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Per-type counts for tab labels.
|
||||
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
|
||||
final params = <String, dynamic>{
|
||||
if (tags.isNotEmpty) 'tags': tags.join(','),
|
||||
};
|
||||
final response = await _dio.get('/api/knowledge/counts', queryParameters: params);
|
||||
return (response.data as Map<String, dynamic>).map(
|
||||
(k, v) => MapEntry(k, (v as num).toInt()),
|
||||
);
|
||||
}
|
||||
|
||||
/// All tags for the current type filter.
|
||||
Future<List<String>> fetchTags({String? noteType}) async {
|
||||
final response = await _dio.get(
|
||||
'/api/knowledge/tags',
|
||||
queryParameters: {if (noteType != null) 'type': noteType},
|
||||
);
|
||||
return (response.data['tags'] as List<dynamic>).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<int> ids; // all fetched IDs so far
|
||||
final Map<int, KnowledgeItem> items; // hydrated items
|
||||
final int totalIds;
|
||||
final bool isLoadingIds;
|
||||
final bool isLoadingBatch;
|
||||
final bool hasMore;
|
||||
final String? noteType; // active type filter
|
||||
final List<String> activeTags;
|
||||
final String? searchQuery;
|
||||
final Map<String, int> counts; // per-type counts
|
||||
|
||||
bool get canLoadMore => hasMore && !isLoadingIds;
|
||||
// Items in ID order, only those hydrated
|
||||
List<KnowledgeItem> get orderedItems =>
|
||||
ids.where(items.containsKey).map((id) => items[id]!).toList();
|
||||
}
|
||||
|
||||
class KnowledgeNotifier extends StateNotifier<KnowledgeState> {
|
||||
// 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<void> loadMoreIds() { ... }
|
||||
|
||||
// Hydrate the next 12 un-hydrated IDs from the current list
|
||||
Future<void> hydrateNext() { ... }
|
||||
|
||||
Future<void> refresh() { ... } // reset + reload
|
||||
}
|
||||
|
||||
// Provider
|
||||
final knowledgeProvider =
|
||||
StateNotifierProvider<KnowledgeNotifier, KnowledgeState>(...);
|
||||
```
|
||||
|
||||
**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)
|
||||
@@ -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<VoiceState>` — 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<VoiceState>` (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
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
```
|
||||
|
||||
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)
|
||||
+116
-22
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
|
||||
import 'core/constants.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'core/theme.dart';
|
||||
@@ -15,15 +17,20 @@ 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';
|
||||
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
|
||||
@@ -79,7 +86,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.noteNew,
|
||||
builder: (_, _) => const NoteEditScreen(),
|
||||
builder: (_, state) {
|
||||
final extra = state.extra as Map<String, dynamic>?;
|
||||
return NoteEditScreen(noteType: extra?['noteType'] as String?);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.noteDetail,
|
||||
@@ -107,6 +117,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
taskId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
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']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.chat,
|
||||
builder: (_, state) => ChatScreen(
|
||||
@@ -121,13 +147,17 @@ final routerProvider = Provider<GoRouter>((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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -145,22 +175,37 @@ class _Shell extends ConsumerStatefulWidget {
|
||||
class _ShellState extends ConsumerState<_Shell> {
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
Routes.library,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
Routes.projects,
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Silent update check on first app load.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Silent update check — only if we haven't already checked this session.
|
||||
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
||||
if (repoUrl != null && repoUrl.isNotEmpty) {
|
||||
ref.read(updateProvider.notifier).check(repoUrl);
|
||||
final status = ref.read(updateProvider).status;
|
||||
if (status == UpdateStatus.idle || status == UpdateStatus.error) {
|
||||
ref.read(updateProvider.notifier).check(repoUrl);
|
||||
}
|
||||
}
|
||||
// Sync device timezone to backend so briefing and chat use local time.
|
||||
_syncTimezone();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _syncTimezone() async {
|
||||
try {
|
||||
final tzInfo = await FlutterTimezone.getLocalTimezone();
|
||||
await ref.read(settingsApiProvider).syncTimezone(tzInfo.identifier);
|
||||
} catch (_) {
|
||||
// Best-effort — failure is non-critical.
|
||||
}
|
||||
}
|
||||
|
||||
int _tabIndex(String location) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
@@ -181,7 +226,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Version ${state.latestVersion} is ready to install.'),
|
||||
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
|
||||
if (state.currentVersion != null)
|
||||
Text(
|
||||
'Installed: v${state.currentVersion}',
|
||||
@@ -201,6 +246,16 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (state.status == UpdateStatus.error &&
|
||||
state.errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
state.errorMessage!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -208,7 +263,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
if (!isDownloading)
|
||||
if (!isDownloading && state.downloadUrl != null)
|
||||
FilledButton(
|
||||
onPressed: () => ref
|
||||
.read(updateProvider.notifier)
|
||||
@@ -257,15 +312,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),
|
||||
@@ -302,15 +362,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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -336,6 +401,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -356,8 +422,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
if (!mounted) break;
|
||||
try {
|
||||
final result = await api.capture(text);
|
||||
if (!mounted) break;
|
||||
// Dequeue before the mounted check — SharedPreferences doesn't need
|
||||
// the widget alive, and skipping this would leave a ghost item.
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
if (!mounted) break;
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
@@ -368,16 +436,35 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
} on NetworkException {
|
||||
break;
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
// Server error or unexpected failure — drop from queue to prevent
|
||||
// ghost items that can never be cleared.
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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.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…';
|
||||
}
|
||||
@@ -417,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),
|
||||
@@ -447,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',
|
||||
|
||||
@@ -9,11 +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';
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ class _ErrorInterceptor extends Interceptor {
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (err.type == DioExceptionType.receiveTimeout ||
|
||||
err.type == DioExceptionType.sendTimeout) {
|
||||
handler.reject(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: const AppException('Request timed out. The server is taking too long to respond.'),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
));
|
||||
return;
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +59,23 @@ class BriefingApi {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"}
|
||||
Future<void> postRssReaction(int rssItemId, String reaction) async {
|
||||
try {
|
||||
await _dio.post('/api/briefing/rss-reactions',
|
||||
data: {'rss_item_id': rssItemId, 'reaction': reaction});
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/briefing/rss-reactions/{rssItemId}
|
||||
Future<void> deleteRssReaction(int rssItemId) async {
|
||||
try {
|
||||
await _dio.delete('/api/briefing/rss-reactions/$rssItemId');
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>, int)> fetchIds({
|
||||
String? noteType,
|
||||
List<String> tags = const [],
|
||||
String sort = 'modified',
|
||||
String? q,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'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<String, dynamic>;
|
||||
final ids =
|
||||
(data['ids'] as List<dynamic>).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<List<KnowledgeItem>> fetchBatch(List<int> 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<String, dynamic>;
|
||||
return (data['items'] as List<dynamic>)
|
||||
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-type counts for tab labels.
|
||||
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
if (tags.isNotEmpty) 'tags': tags.join(','),
|
||||
};
|
||||
final response = await _dio.get('/api/knowledge/counts',
|
||||
queryParameters: params);
|
||||
return (response.data as Map<String, dynamic>)
|
||||
.map((k, v) => MapEntry(k, (v as num).toInt()));
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// All tags for the current type filter.
|
||||
Future<List<String>> 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<dynamic>)
|
||||
.map((e) => e as String)
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,14 @@ class NotesApi {
|
||||
String body, {
|
||||
List<String> 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<String, dynamic>);
|
||||
@@ -53,12 +55,14 @@ class NotesApi {
|
||||
List<String> 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<String, dynamic>);
|
||||
|
||||
@@ -7,11 +7,19 @@ class ProjectsApi {
|
||||
final Dio _dio;
|
||||
const ProjectsApi(this._dio);
|
||||
|
||||
Future<List<Project>> getAll({String? status}) async {
|
||||
Future<List<Project>> getAll({
|
||||
String? status,
|
||||
String sort = 'updated_at',
|
||||
String order = 'desc',
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/projects',
|
||||
queryParameters: status != null ? {'status': status} : null,
|
||||
queryParameters: {
|
||||
'sort': sort,
|
||||
'order': order,
|
||||
if (status != null) 'status': status,
|
||||
},
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['projects'] as List<dynamic>;
|
||||
@@ -37,10 +45,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,
|
||||
|
||||
@@ -40,6 +40,7 @@ class QuickCaptureApi {
|
||||
final response = await _dio.post(
|
||||
'/api/quick-capture',
|
||||
data: {'text': text},
|
||||
options: Options(receiveTimeout: const Duration(seconds: 120)),
|
||||
);
|
||||
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class SettingsApi {
|
||||
const SettingsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<void> syncTimezone(String ianaTimezone) async {
|
||||
await _dio.put<void>(
|
||||
'/api/settings',
|
||||
data: {'user_timezone': ianaTimezone},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,24 @@ class TasksApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getByProject(int projectId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/notes',
|
||||
queryParameters: {
|
||||
'project_id': projectId,
|
||||
'is_task': 'true',
|
||||
'limit': 500,
|
||||
},
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['notes'] as List<dynamic>;
|
||||
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getSubTasks(int parentId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
|
||||
@@ -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<String, dynamic> 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<VoiceStatus> checkStatus() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/voice/status');
|
||||
return VoiceStatus.fromJson(response.data as Map<String, dynamic>);
|
||||
} 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<String> 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<String, dynamic>;
|
||||
return (data['transcript'] as String? ?? '').trim();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST text and return raw WAV bytes.
|
||||
Future<Uint8List> 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<int>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String, dynamic> 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<dynamic>?)
|
||||
?.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),
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ class Message {
|
||||
final String content;
|
||||
final String status; // "complete" | "generating"
|
||||
final DateTime? createdAt;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const Message({
|
||||
this.id,
|
||||
@@ -15,6 +16,7 @@ class Message {
|
||||
required this.content,
|
||||
this.status = 'complete',
|
||||
this.createdAt,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) => Message(
|
||||
@@ -26,6 +28,7 @@ class Message {
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: null,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Message copyWith({String? content, String? status}) => Message(
|
||||
@@ -35,5 +38,6 @@ class Message {
|
||||
content: content ?? this.content,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ class Note {
|
||||
final String title;
|
||||
final String body;
|
||||
final List<String> 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<String>? 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?,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>, int)> fetchIds({
|
||||
String? noteType,
|
||||
List<String> 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<List<KnowledgeItem>> fetchBatch(List<int> ids) =>
|
||||
_api.fetchBatch(ids);
|
||||
|
||||
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) =>
|
||||
_api.fetchCounts(tags: tags);
|
||||
|
||||
Future<List<String>> fetchTags({String? noteType}) =>
|
||||
_api.fetchTags(noteType: noteType);
|
||||
}
|
||||
@@ -13,8 +13,10 @@ class NotesRepository {
|
||||
String body, {
|
||||
List<String> 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<Note> update(
|
||||
int id,
|
||||
@@ -23,9 +25,13 @@ class NotesRepository {
|
||||
List<String> 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<void> delete(int id) => _api.delete(id);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,26 @@ class ProjectsRepository {
|
||||
final ProjectsApi _api;
|
||||
const ProjectsRepository(this._api);
|
||||
|
||||
Future<List<Project>> getAll({String? status}) => _api.getAll(status: status);
|
||||
Future<List<Project>> getAll({
|
||||
String? status,
|
||||
String sort = 'updated_at',
|
||||
String order = 'desc',
|
||||
}) =>
|
||||
_api.getAll(status: status, sort: sort, order: order);
|
||||
Future<Project> getOne(int id) => _api.getOne(id);
|
||||
Future<Project> create({
|
||||
required String title,
|
||||
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<Project> update(int id, Map<String, dynamic> fields) =>
|
||||
_api.update(id, fields);
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
|
||||
@@ -27,6 +27,7 @@ class TasksRepository {
|
||||
parentId: parentId,
|
||||
);
|
||||
|
||||
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
|
||||
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
||||
|
||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../api/voice_api.dart';
|
||||
|
||||
class VoiceRepository {
|
||||
final VoiceApi _api;
|
||||
const VoiceRepository(this._api);
|
||||
|
||||
Future<VoiceStatus> checkStatus() => _api.checkStatus();
|
||||
Future<String> transcribe(Uint8List audioBytes) => _api.transcribe(audioBytes);
|
||||
Future<Uint8List> synthesise(String text) => _api.synthesise(text);
|
||||
}
|
||||
@@ -6,13 +6,18 @@ 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/voice_api.dart';
|
||||
import '../data/api/milestones_api.dart';
|
||||
import '../data/api/notes_api.dart';
|
||||
import '../data/api/projects_api.dart';
|
||||
import '../data/api/quick_capture_api.dart';
|
||||
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/voice_repository.dart';
|
||||
import '../data/repositories/milestones_repository.dart';
|
||||
import '../data/repositories/notes_repository.dart';
|
||||
import '../data/repositories/projects_repository.dart';
|
||||
@@ -82,6 +87,26 @@ final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
||||
});
|
||||
|
||||
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
|
||||
return KnowledgeApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
|
||||
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
|
||||
});
|
||||
|
||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
||||
return BriefingApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
||||
return SettingsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final voiceApiProvider = Provider<VoiceApi>((ref) {
|
||||
return VoiceApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
||||
return VoiceRepository(ref.watch(voiceApiProvider));
|
||||
});
|
||||
|
||||
@@ -23,6 +23,24 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
return ref.read(briefingApiProvider).getToday();
|
||||
}
|
||||
|
||||
/// Silently fetch the latest briefing and patch state without triggering
|
||||
/// AsyncLoading — existing content stays visible while the fetch is in flight.
|
||||
Future<void> silentRefresh() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
try {
|
||||
final fresh = await ref.read(briefingApiProvider).getToday();
|
||||
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
|
||||
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
|
||||
if (fresh.messages.length != current.messages.length ||
|
||||
newLast?.content != curLast?.content) {
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
} catch (_) {
|
||||
// Network hiccup — silently ignore, keep existing content
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a briefing slot (e.g. "compilation") then reload.
|
||||
Future<void> refresh(String slot) async {
|
||||
await ref.read(briefingApiProvider).triggerSlot(slot);
|
||||
|
||||
@@ -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<int> ids;
|
||||
final Map<int, KnowledgeItem> items;
|
||||
final int totalIds;
|
||||
final bool isLoadingIds;
|
||||
final bool isLoadingBatch;
|
||||
final bool hasMore;
|
||||
final String? noteType; // null = All
|
||||
final List<String> activeTags;
|
||||
final String? searchQuery;
|
||||
final Map<String, int> counts;
|
||||
final List<String> 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<KnowledgeItem> get orderedItems =>
|
||||
ids.where(items.containsKey).map((id) => items[id]!).toList();
|
||||
|
||||
/// IDs that have been fetched but not yet hydrated.
|
||||
List<int> get unhydratedIds =>
|
||||
ids.where((id) => !items.containsKey(id)).toList();
|
||||
|
||||
KnowledgeState copyWith({
|
||||
List<int>? ids,
|
||||
Map<int, KnowledgeItem>? items,
|
||||
int? totalIds,
|
||||
bool? isLoadingIds,
|
||||
bool? isLoadingBatch,
|
||||
bool? hasMore,
|
||||
Object? noteType = _keep,
|
||||
List<String>? activeTags,
|
||||
Object? searchQuery = _keep,
|
||||
Map<String, int>? counts,
|
||||
List<String>? 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<KnowledgeState> {
|
||||
@override
|
||||
KnowledgeState build() => const KnowledgeState();
|
||||
|
||||
KnowledgeRepository get _repo => ref.read(knowledgeRepositoryProvider);
|
||||
|
||||
// ── Filter setters — each resets and re-fetches ──────────────────────────
|
||||
|
||||
Future<void> setTypeFilter(String? noteType) async {
|
||||
state = KnowledgeState(noteType: noteType, activeTags: state.activeTags);
|
||||
await _fetchFromScratch();
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> 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<void> 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<int, KnowledgeItem>.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<void> _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<void> _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<void> _loadCounts() async {
|
||||
try {
|
||||
final counts = await _repo.fetchCounts(tags: state.activeTags);
|
||||
state = state.copyWith(counts: counts);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _loadTags() async {
|
||||
try {
|
||||
final tags = await _repo.fetchTags(noteType: state.noteType);
|
||||
state = state.copyWith(availableTags: tags);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
final knowledgeProvider =
|
||||
NotifierProvider<KnowledgeNotifier, KnowledgeState>(KnowledgeNotifier.new);
|
||||
@@ -17,10 +17,14 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
String body, {
|
||||
List<String> 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<Note>> {
|
||||
List<String> 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<List<Note>> {
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
clearProject: clearProject,
|
||||
noteType: noteType,
|
||||
);
|
||||
state = AsyncData([
|
||||
for (final n in state.value ?? [])
|
||||
|
||||
@@ -10,7 +10,9 @@ final projectsProvider =
|
||||
class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
@override
|
||||
Future<List<Project>> build() async {
|
||||
return ref.watch(projectsRepositoryProvider).getAll();
|
||||
return ref
|
||||
.watch(projectsRepositoryProvider)
|
||||
.getAll(sort: 'updated_at', order: 'desc');
|
||||
}
|
||||
|
||||
Future<Project> create({
|
||||
|
||||
@@ -6,6 +6,11 @@ import 'api_client_provider.dart';
|
||||
final tasksProvider =
|
||||
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
|
||||
|
||||
final projectTasksProvider =
|
||||
FutureProvider.family<List<Task>, int>((ref, projectId) {
|
||||
return ref.watch(tasksRepositoryProvider).getByProject(projectId);
|
||||
});
|
||||
|
||||
class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
@override
|
||||
Future<List<Task>> build() async {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
||||
|
||||
@@ -102,6 +103,22 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
|
||||
Future<void> downloadAndInstall() async {
|
||||
if (state.downloadUrl == null) return;
|
||||
|
||||
// Android 8+ requires explicit per-app "Install unknown apps" approval
|
||||
// beyond the manifest declaration. Check and redirect to Settings if needed.
|
||||
final installPermission = await Permission.requestInstallPackages.status;
|
||||
if (!installPermission.isGranted) {
|
||||
final result = await Permission.requestInstallPackages.request();
|
||||
if (!result.isGranted) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage:
|
||||
'Grant "Install unknown apps" permission for Fabled in Settings, then try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
||||
|
||||
try {
|
||||
@@ -119,14 +136,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
},
|
||||
);
|
||||
|
||||
await OpenFile.open(
|
||||
final result = await OpenFile.open(
|
||||
path,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
|
||||
// Transition to idle — the system installer is now open.
|
||||
// Going back to `available` would re-trigger the update dialog loop.
|
||||
state = const UpdateState();
|
||||
if (result.type == ResultType.done) {
|
||||
// Installer launched — reset to idle so the dialog closes naturally.
|
||||
state = const UpdateState();
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage: 'Could not open installer: ${result.message}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
|
||||
@@ -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<String> 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 = <String>[];
|
||||
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, VoiceState>(VoiceNotifier.new);
|
||||
|
||||
// ── Notifier ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class VoiceNotifier extends Notifier<VoiceState> {
|
||||
// Audio I/O
|
||||
AudioRecorder? _recorder;
|
||||
AudioPlayer? _player;
|
||||
StreamSubscription<Amplitude>? _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<void> Function(String transcript)? _onTranscript;
|
||||
bool _enableTts = false;
|
||||
|
||||
// Streaming TTS state
|
||||
String _sentenceBuffer = '';
|
||||
int _lastSeenLength = 0;
|
||||
bool _streamComplete = false;
|
||||
|
||||
// TTS playback queue
|
||||
final _ttsQueue = Queue<Uint8List>();
|
||||
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<void> enterVoiceMode({
|
||||
required Future<void> 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<void> _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<void> _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<void> _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<void> _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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/models/message.dart';
|
||||
import '../../providers/briefing_provider.dart';
|
||||
import '../../widgets/briefing_digest_card.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
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});
|
||||
@@ -15,15 +21,43 @@ class BriefingScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
||||
}
|
||||
|
||||
class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _refreshing = false;
|
||||
// rss_item_id -> 'up' | 'down' | null
|
||||
final Map<int, String?> _reactions = {};
|
||||
|
||||
Timer? _pollTimer;
|
||||
bool _appInForeground = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_appInForeground = state == AppLifecycleState.resumed;
|
||||
}
|
||||
|
||||
void _pollSilently() {
|
||||
if (!_appInForeground || !mounted) return;
|
||||
final isStreaming = ref.read(isBriefingStreamingProvider);
|
||||
if (isStreaming) return;
|
||||
ref.read(briefingProvider.notifier).silentRefresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -59,6 +93,42 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleReaction(int itemId, String reaction) async {
|
||||
final current = _reactions[itemId];
|
||||
final next = current == reaction ? null : reaction;
|
||||
setState(() => _reactions[itemId] = next);
|
||||
final api = ref.read(briefingApiProvider);
|
||||
try {
|
||||
if (next == null) {
|
||||
await api.deleteRssReaction(itemId);
|
||||
} else {
|
||||
await api.postRssReaction(itemId, reaction);
|
||||
}
|
||||
} catch (_) {
|
||||
setState(() => _reactions[itemId] = current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
@@ -78,10 +148,24 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
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(
|
||||
@@ -132,7 +216,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
),
|
||||
body: briefingAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
error: (err, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -146,47 +230,51 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
),
|
||||
),
|
||||
data: (conv) {
|
||||
// First assistant message for the digest card (null if none yet)
|
||||
final Message? firstAssistant = conv.messages
|
||||
.where((m) => m.role == MessageRole.assistant)
|
||||
.toList()
|
||||
.firstOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Digest card header
|
||||
BriefingDigestCard(
|
||||
message: firstAssistant,
|
||||
onGenerateNow: _refresh,
|
||||
),
|
||||
|
||||
// Divider + label
|
||||
if (conv.messages.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text(
|
||||
'Conversation',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
]),
|
||||
],
|
||||
|
||||
// Message list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
itemCount: conv.messages.length,
|
||||
itemBuilder: (_, i) =>
|
||||
ChatMessageBubble(message: conv.messages[i]),
|
||||
slivers: [
|
||||
if (conv.messages.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'No briefing yet today.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('Generate now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: conv.messages.length,
|
||||
itemBuilder: (_, i) {
|
||||
final msg = conv.messages[i];
|
||||
return _BriefingMessageItem(
|
||||
message: msg,
|
||||
reactions: _reactions,
|
||||
onReaction: _handleReaction,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -197,6 +285,21 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
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(
|
||||
@@ -207,22 +310,35 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
@@ -250,6 +366,57 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a single briefing message with optional WeatherCard above it
|
||||
/// and RSS reaction buttons below it (for assistant messages with metadata).
|
||||
class _BriefingMessageItem extends StatelessWidget {
|
||||
final Message message;
|
||||
final Map<int, String?> reactions;
|
||||
final void Function(int itemId, String reaction) onReaction;
|
||||
|
||||
const _BriefingMessageItem({
|
||||
required this.message,
|
||||
required this.reactions,
|
||||
required this.onReaction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = message.metadata;
|
||||
final isAssistant = message.role == MessageRole.assistant;
|
||||
|
||||
// Weather: show card above when metadata.weather key is present (even if null value)
|
||||
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
|
||||
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
|
||||
|
||||
// RSS news cards
|
||||
final rssItemsRaw = isAssistant && meta != null
|
||||
? (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? []
|
||||
: <Map<String, dynamic>>[];
|
||||
final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasWeatherKey) WeatherCard(weather: weatherData),
|
||||
ChatMessageBubble(message: message),
|
||||
if (rssItems.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: rssItems.map((item) => NewsCard(
|
||||
item: item,
|
||||
reaction: reactions[item.id],
|
||||
onReaction: onReaction,
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _GradientSendButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final bool isStreaming;
|
||||
|
||||
@@ -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<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
// 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<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<KnowledgeScreen> createState() => _KnowledgeScreenState();
|
||||
}
|
||||
|
||||
class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
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<String, int> counts) {
|
||||
final tab = _kTabs[index];
|
||||
if (tab.type == null) {
|
||||
final total = counts.values.fold(0, (a, b) => a + b);
|
||||
return total > 0 ? 'All ($total)' : 'All';
|
||||
}
|
||||
final count = counts[tab.type];
|
||||
return count != null && count > 0 ? '${tab.label} ($count)' : tab.label;
|
||||
}
|
||||
|
||||
@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<void>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<LibraryScreen> createState() => _LibraryScreenState();
|
||||
}
|
||||
|
||||
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
|
||||
_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<List<Note>> 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<List<Task>> 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<List<dynamic>> 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<List<Note>> notesAsync,
|
||||
AsyncValue<List<Task>> 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<void>(
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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/milestone.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/milestones_provider.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class ProjectTasksScreen extends ConsumerStatefulWidget {
|
||||
final int projectId;
|
||||
const ProjectTasksScreen({super.key, required this.projectId});
|
||||
|
||||
@override
|
||||
ConsumerState<ProjectTasksScreen> createState() => _ProjectTasksScreenState();
|
||||
}
|
||||
|
||||
class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
// Local optimistic status overrides — avoids a reload flash on every cycle tap.
|
||||
final Map<int, TaskStatus> _pendingStatus = {};
|
||||
|
||||
TaskStatus _effectiveStatus(Task task) =>
|
||||
_pendingStatus[task.id] ?? task.status;
|
||||
|
||||
TaskStatus _nextStatus(TaskStatus s) => switch (s) {
|
||||
TaskStatus.todo => TaskStatus.inProgress,
|
||||
TaskStatus.inProgress => TaskStatus.done,
|
||||
TaskStatus.done => TaskStatus.todo,
|
||||
};
|
||||
|
||||
Future<void> _cycleStatus(Task task) async {
|
||||
final current = _effectiveStatus(task);
|
||||
final next = _nextStatus(current);
|
||||
setState(() => _pendingStatus[task.id] = next);
|
||||
try {
|
||||
await ref
|
||||
.read(tasksRepositoryProvider)
|
||||
.update(task.id, {'status': next.value});
|
||||
// Sync the global tasks list so the library view stays consistent.
|
||||
ref.invalidate(tasksProvider);
|
||||
} catch (_) {
|
||||
// Revert optimistic change on error.
|
||||
setState(() => _pendingStatus.remove(task.id));
|
||||
}
|
||||
}
|
||||
|
||||
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 tasksAsync = ref.watch(projectTasksProvider(widget.projectId));
|
||||
final milestonesAsync = ref.watch(projectMilestonesProvider(widget.projectId));
|
||||
final project = ref.watch(projectsProvider).value
|
||||
?.whereType<Project>()
|
||||
.where((p) => p.id == widget.projectId)
|
||||
.firstOrNull;
|
||||
|
||||
final color = _parseColor(project?.color);
|
||||
|
||||
return Scaffold(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: tasksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error loading tasks: $e')),
|
||||
data: (tasks) => _buildBody(context, tasks, milestonesAsync, color),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
List<Task> tasks,
|
||||
AsyncValue<List<Milestone>> milestonesAsync,
|
||||
Color color,
|
||||
) {
|
||||
final milestones = (milestonesAsync.value ?? []).toList()
|
||||
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
|
||||
|
||||
// Group tasks by milestoneId.
|
||||
final byMilestone = <int?, List<Task>>{};
|
||||
for (final t in tasks) {
|
||||
(byMilestone[t.milestoneId] ??= []).add(t);
|
||||
}
|
||||
|
||||
final unassigned = byMilestone[null] ?? [];
|
||||
|
||||
if (tasks.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_box_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No tasks in this project yet.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
setState(() => _pendingStatus.clear());
|
||||
ref.invalidate(projectTasksProvider(widget.projectId));
|
||||
ref.invalidate(projectMilestonesProvider(widget.projectId));
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// Top colour strip.
|
||||
SliverToBoxAdapter(child: Container(height: 4, color: color)),
|
||||
|
||||
// Milestone sections.
|
||||
for (final ms in milestones) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _MilestoneHeader(
|
||||
milestone: ms,
|
||||
tasks: byMilestone[ms.id] ?? [],
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
if ((byMilestone[ms.id] ?? []).isNotEmpty)
|
||||
SliverList.builder(
|
||||
itemCount: byMilestone[ms.id]!.length,
|
||||
itemBuilder: (_, i) {
|
||||
final task = byMilestone[ms.id]![i];
|
||||
return _TaskRow(
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// Unassigned tasks.
|
||||
if (unassigned.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||
child: Text(
|
||||
'No milestone',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: unassigned.length,
|
||||
itemBuilder: (_, i) {
|
||||
final task = unassigned[i];
|
||||
return _TaskRow(
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Milestone section header ──────────────────────────────────────────────────
|
||||
|
||||
class _MilestoneHeader extends StatelessWidget {
|
||||
final Milestone milestone;
|
||||
final List<Task> tasks;
|
||||
final Color color;
|
||||
|
||||
const _MilestoneHeader({
|
||||
required this.milestone,
|
||||
required this.tasks,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final done = tasks.where((t) => t.status == TaskStatus.done).length;
|
||||
final total = tasks.length;
|
||||
final pct = total > 0 ? done / total : 0.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
milestone.title,
|
||||
style: theme.textTheme.titleSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$done / $total',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (total > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: pct,
|
||||
minHeight: 2,
|
||||
color: color,
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
final TaskStatus effectiveStatus;
|
||||
final VoidCallback onStatusTap;
|
||||
|
||||
const _TaskRow({
|
||||
required this.task,
|
||||
required this.effectiveStatus,
|
||||
required this.onStatusTap,
|
||||
});
|
||||
|
||||
IconData get _statusIcon => switch (effectiveStatus) {
|
||||
TaskStatus.done => Icons.check_circle,
|
||||
TaskStatus.inProgress => Icons.timelapse,
|
||||
TaskStatus.todo => Icons.radio_button_unchecked,
|
||||
};
|
||||
|
||||
Color _statusColor(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return switch (effectiveStatus) {
|
||||
TaskStatus.done => const Color(0xFF22C55E),
|
||||
TaskStatus.inProgress => cs.primary,
|
||||
TaskStatus.todo => cs.onSurfaceVariant,
|
||||
};
|
||||
}
|
||||
|
||||
Color _priorityColor(BuildContext context) => switch (task.priority) {
|
||||
TaskPriority.high => const Color(0xFFEF4444),
|
||||
TaskPriority.medium => const Color(0xFFF59E0B),
|
||||
_ => Colors.transparent,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
child: InkWell(
|
||||
onTap: () => context
|
||||
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(_statusIcon, color: _statusColor(context)),
|
||||
onPressed: onStatusTap,
|
||||
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: effectiveStatus == TaskStatus.done
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
color: effectiveStatus == TaskStatus.done
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: null,
|
||||
),
|
||||
maxLines: 2,
|
||||
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()) &&
|
||||
effectiveStatus != TaskStatus.done
|
||||
? const Color(0xFFEF4444)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (task.priority == TaskPriority.high ||
|
||||
task.priority == TaskPriority.medium)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _priorityColor(context),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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}';
|
||||
}
|
||||
@@ -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<NoteEditScreen> createState() => _NoteEditScreenState();
|
||||
@@ -25,12 +26,14 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
int? _projectId;
|
||||
bool _preview = false;
|
||||
bool _saving = false;
|
||||
late String _noteType;
|
||||
|
||||
late final Future<void> _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<NoteEditScreen> {
|
||||
_contentController.text = note.body;
|
||||
_tags = List<String>.from(note.tags);
|
||||
_projectId = note.projectId;
|
||||
_noteType = note.noteType;
|
||||
}
|
||||
|
||||
void _addTag(String raw) {
|
||||
@@ -108,6 +112,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
body,
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
noteType: _noteType,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
} else {
|
||||
@@ -118,6 +123,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
clearProject: _projectId == null,
|
||||
noteType: _noteType,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
@@ -138,7 +144,22 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
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(
|
||||
|
||||
@@ -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<ProjectEditScreen> createState() => _ProjectEditScreenState();
|
||||
}
|
||||
|
||||
class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
|
||||
final _titleController = TextEditingController();
|
||||
final _descController = TextEditingController();
|
||||
final _goalController = TextEditingController();
|
||||
String _status = 'active';
|
||||
bool _saving = false;
|
||||
late final Future<void> _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<void> _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<void> _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<String>(
|
||||
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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,271 +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: () {}, // No project detail screen in this app
|
||||
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}';
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class RssItemMeta {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String source;
|
||||
final String snippet;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
const RssItemMeta({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.source,
|
||||
required this.snippet,
|
||||
this.publishedAt,
|
||||
});
|
||||
|
||||
factory RssItemMeta.fromJson(Map<String, dynamic> json) => RssItemMeta(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
snippet: json['snippet'] as String? ?? '',
|
||||
publishedAt: json['published_at'] != null
|
||||
? DateTime.tryParse(json['published_at'] as String)
|
||||
: null,
|
||||
);
|
||||
|
||||
String get relativeDate {
|
||||
if (publishedAt == null) return '';
|
||||
final diff = DateTime.now().difference(publishedAt!);
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inHours < 48) return 'Yesterday';
|
||||
return '${publishedAt!.month}/${publishedAt!.day}';
|
||||
}
|
||||
}
|
||||
|
||||
class NewsCard extends StatelessWidget {
|
||||
final RssItemMeta item;
|
||||
final String? reaction; // 'up' | 'down' | null
|
||||
final void Function(int itemId, String reaction) onReaction;
|
||||
|
||||
const NewsCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.reaction,
|
||||
required this.onReaction,
|
||||
});
|
||||
|
||||
Future<void> _openUrl() async {
|
||||
if (item.url.isEmpty) return;
|
||||
final uri = Uri.tryParse(item.url);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Source + date row
|
||||
Row(
|
||||
children: [
|
||||
if (item.source.isNotEmpty)
|
||||
Text(
|
||||
item.source.toUpperCase(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
if (item.source.isNotEmpty && item.relativeDate.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
item.relativeDate,
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Title — tappable if URL present
|
||||
GestureDetector(
|
||||
onTap: item.url.isNotEmpty ? _openUrl : null,
|
||||
child: Text(
|
||||
item.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: item.url.isNotEmpty ? scheme.primary : scheme.onSurface,
|
||||
decoration: item.url.isNotEmpty ? TextDecoration.underline : null,
|
||||
decorationColor: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Snippet
|
||||
if (item.snippet.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.snippet,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
// Reaction buttons
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ReactionButton(
|
||||
emoji: '👍',
|
||||
active: reaction == 'up',
|
||||
onTap: () => onReaction(item.id, 'up'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_ReactionButton(
|
||||
emoji: '👎',
|
||||
active: reaction == 'down',
|
||||
onTap: () => onReaction(item.id, 'down'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionButton extends StatelessWidget {
|
||||
final String emoji;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReactionButton({
|
||||
required this.emoji,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? scheme.primary.withValues(alpha: 0.12) : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: active ? scheme.primary : scheme.outlineVariant,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<VoiceMicButton> createState() => _VoiceMicButtonState();
|
||||
}
|
||||
|
||||
class _VoiceMicButtonState extends State<VoiceMicButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
_pulseAnimation = Tween<double>(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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class WeatherCard extends StatelessWidget {
|
||||
final Map<String, dynamic>? weather;
|
||||
|
||||
const WeatherCard({super.key, required this.weather});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (weather == null) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
'Weather data unavailable — will retry at next slot.',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final w = weather!;
|
||||
final location = w['location'] as String? ?? '';
|
||||
final currentTemp = w['current_temp'];
|
||||
final condition = w['condition'] as String? ?? '';
|
||||
final todayHigh = w['today_high'];
|
||||
final todayLow = w['today_low'];
|
||||
final yesterdayHigh = w['yesterday_high'];
|
||||
final fetchedAt = w['fetched_at'] as String?;
|
||||
final forecast = (w['forecast'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
|
||||
String? tempDelta;
|
||||
if (todayHigh != null && yesterdayHigh != null) {
|
||||
final diff = (todayHigh as num) - (yesterdayHigh as num);
|
||||
if (diff.abs() < 1) {
|
||||
tempDelta = 'Same as yesterday';
|
||||
} else {
|
||||
final dir = diff > 0 ? 'warmer' : 'cooler';
|
||||
tempDelta = '${diff.abs().round()}° $dir than yesterday';
|
||||
}
|
||||
}
|
||||
|
||||
String? fetchedLabel;
|
||||
if (fetchedAt != null) {
|
||||
try {
|
||||
final dt = DateTime.parse(fetchedAt).toLocal();
|
||||
final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12;
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
final period = dt.hour < 12 ? 'AM' : 'PM';
|
||||
fetchedLabel = '$h:$m $period';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: location + fetched time
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
location,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (fetchedLabel != null)
|
||||
Text(
|
||||
'as of $fetchedLabel',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Current temp + condition
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$currentTemp°',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSurface,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
condition,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Today high/low + delta
|
||||
if (todayHigh != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Today: $todayHigh° / $todayLow°'
|
||||
'${tempDelta != null ? ' · $tempDelta' : ''}',
|
||||
style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
// Forecast strip
|
||||
if (forecast.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: forecast.map((day) {
|
||||
return SizedBox(
|
||||
width: 64,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
day['day'] as String? ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
day['condition'] as String? ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${day['high']}° / ${day['low']}°',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,18 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_timezone/flutter_timezone_plugin.h>
|
||||
#include <open_file_linux/open_file_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_timezone
|
||||
open_file_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
+248
@@ -41,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
audio_session:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: audio_session
|
||||
sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.25"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -169,6 +177,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -193,6 +209,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -299,6 +323,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_timezone
|
||||
sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -392,6 +424,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.0"
|
||||
just_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: just_audio
|
||||
sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.46"
|
||||
just_audio_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: just_audio_platform_interface
|
||||
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
just_audio_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: just_audio_web
|
||||
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.16"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -640,6 +696,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -688,6 +792,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: record
|
||||
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
record_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_android
|
||||
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.1"
|
||||
record_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_darwin
|
||||
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
record_linux:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: record_linux
|
||||
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
record_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_platform_interface
|
||||
sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
record_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_web
|
||||
sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
record_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_windows
|
||||
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -696,6 +856,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -845,6 +1013,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -893,6 +1069,78 @@ 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"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -22,9 +22,17 @@ dependencies:
|
||||
markdown: ^7.2.2
|
||||
package_info_plus: ^9.0.0
|
||||
open_file: ^3.3.2
|
||||
permission_handler: ^11.3.1
|
||||
flutter_inappwebview: ^6.1.5
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
google_fonts: ^8.0.2
|
||||
flutter_timezone: ^5.0.2
|
||||
url_launcher: ^6.3.1
|
||||
record: ^5.0.0
|
||||
just_audio: ^0.9.39
|
||||
|
||||
dependency_overrides:
|
||||
record_linux: ^1.3.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
+144
-3
@@ -1,8 +1,149 @@
|
||||
// Placeholder — integration tests go here once the app is running on device.
|
||||
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';
|
||||
|
||||
void main() {
|
||||
test('placeholder', () {
|
||||
expect(true, isTrue);
|
||||
group('Note.fromJson', () {
|
||||
test('parses noteType when present', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'title': 'Alice',
|
||||
'body': '',
|
||||
'tags': <dynamic>[],
|
||||
'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': <dynamic>[],
|
||||
'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': <dynamic>[],
|
||||
'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': <dynamic>[],
|
||||
'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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,8 +7,17 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||
FlutterTimezonePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user