Compare commits
116 Commits
v26.03.26.3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c3a650e3f5 | |||
| a77b71e0e2 | |||
| 116dc42923 | |||
| 76aff4ea9e | |||
| 7df7b5ff85 | |||
| fe10067761 | |||
| 6ef658558a | |||
| b9e68e3bc8 | |||
| 1d9e4af6f3 | |||
| 0f05f47eef | |||
| 5bdd4f565b | |||
| dd250788f6 | |||
| 5e48a4fb69 | |||
| 01aa362d3c | |||
| 3c9602c7c9 | |||
| aba0ca6256 | |||
| 70a3279192 | |||
| fc6c9648f9 | |||
| 413b82f724 | |||
| 5f11b344a3 | |||
| 8959b62abe | |||
| c967a49e5a | |||
| 58d4cfab4d | |||
| ee0f354312 | |||
| bdaa5210f0 | |||
| ad20c9f9d4 | |||
| 48c134ce6a | |||
| 634b6d05cf | |||
| 00878a8a42 | |||
| ddbf867b03 | |||
| 51f1cffe79 | |||
| fa84e40efc | |||
| 1c4e3c018b | |||
| 75b7d6d0fe | |||
| 8ea244ecaa | |||
| a3fe0b4b61 | |||
| 6771ec5e81 | |||
| 4240c90d55 | |||
| 36644cf8a5 | |||
| cb5ce44bbe | |||
| 356709856f | |||
| 6e067f99ef | |||
| ab3a482705 | |||
| 47c190891e | |||
| 3e888b6458 | |||
| 6c29b685e8 | |||
| 5957551546 | |||
| d2582f9111 | |||
| 36350d35b1 | |||
| 96e6b6466f | |||
| d75d34ce8e | |||
| 1c97f9dea5 | |||
| c177bf0691 | |||
| 4ebc57d2e5 | |||
| 946b70ecc4 | |||
| 6ea268bf58 | |||
| 7e332530fb | |||
| 79dce1a01c | |||
| cb3a09756f | |||
| d441dcf954 | |||
| 03dc9108a3 | |||
| 5014eca9ac | |||
| d530920284 | |||
| e2a358a158 | |||
| 4919f7a185 | |||
| 5b639dbd4c | |||
| 334882520c | |||
| c4dca6d4ed | |||
| 776b394874 | |||
| b56c0fc02d | |||
| 8a0837a843 | |||
| 8e5a95b0f2 | |||
| 3a07221968 | |||
| e7d174cef7 | |||
| f4e39c00eb | |||
| e08a8906e3 | |||
| 01ea6b48db | |||
| b56f3d3a0f | |||
| 77fc82af45 | |||
| a2fc0d6c7d | |||
| 2a2f9e6e85 | |||
| 95d0f529ea | |||
| 36bc36cd9d | |||
| a23af0658a | |||
| 39d9f7e053 | |||
| 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 | |||
| 6af23fc853 |
+53
-57
@@ -1,6 +1,12 @@
|
||||
# CI runs only on release tags.
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# Tag v*: analyze + test → build APK → attach to Forgejo Release
|
||||
# Push to dev: flutter analyze + flutter test
|
||||
# Tag v* (release): gates + signed APK build + attach to Forgejo Release
|
||||
#
|
||||
# main pushes are NOT gated here: a merge to main only happens after
|
||||
# dev has already passed CI, and the release tag is the sole trigger
|
||||
# for a signed APK build. Re-running analyze+test on the merge commit
|
||||
# just burns runner time without changing the outcome.
|
||||
#
|
||||
# To cut a release:
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
@@ -11,19 +17,39 @@
|
||||
# commands directly instead.
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
|
||||
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
|
||||
# RELEASE_KEYSTORE_BASE64 — base64 of the signing keystore
|
||||
# RELEASE_KEYSTORE_PASSWORD — keystore + key password
|
||||
# RELEASE_KEY_ALIAS — key alias within the keystore
|
||||
name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
tags: ["v*"]
|
||||
|
||||
# Cancel older runs on the same branch when a newer push lands. Tag runs
|
||||
# are never cancelled so a release build can't kill itself mid-flight.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
# Least-privilege default. The build job upgrades to contents: write
|
||||
# so it can attach the APK to a Forgejo release.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze & test
|
||||
runs-on: py3.12-node22
|
||||
runs-on: ci-runner
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/flutter:stable
|
||||
# Pinned to a specific Flutter version for reproducible builds.
|
||||
# Floating :stable means a random Flutter minor bump could change
|
||||
# analyzer output or break the build without any commit landing.
|
||||
# Bump this line (and verify locally with `flutter --version`)
|
||||
# when you intentionally want a newer Flutter.
|
||||
image: ghcr.io/cirruslabs/flutter:3.41.6
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -42,9 +68,14 @@ jobs:
|
||||
build:
|
||||
name: Build release APK
|
||||
needs: [analyze]
|
||||
runs-on: py3.12-node22
|
||||
# Only tag pushes produce a signed release build. dev pushes
|
||||
# run the gates above and stop there.
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ci-runner
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/flutter:stable
|
||||
image: ghcr.io/cirruslabs/flutter:3.41.6
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -54,6 +85,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)
|
||||
@@ -64,57 +105,12 @@ jobs:
|
||||
--build-name="$BUILD_NAME" \
|
||||
--build-number="$BUILD_NUMBER"
|
||||
|
||||
- name: Set artifact name
|
||||
id: artifact
|
||||
run: |
|
||||
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload artifact to Forgejo
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
APK: build/app/outputs/flutter-apk/app-release.apk
|
||||
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
|
||||
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
|
||||
run: |
|
||||
# Upload the APK as a workflow artifact via the Forgejo API.
|
||||
curl -s -X POST "$API/actions/artifacts" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-F "name=$ARTIFACT_NAME" \
|
||||
-F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)."
|
||||
|
||||
- name: Publish Forgejo release
|
||||
# Release-publish logic lives in a shell script so it's
|
||||
# testable locally (bash -x scripts/publish_apk_release.sh
|
||||
# with env vars set) instead of trapped in YAML.
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
|
||||
run: |
|
||||
# Look for an existing release (created via the UI or a prior run).
|
||||
EXISTING=$(curl -s \
|
||||
"$API/releases/tags/$TAG" \
|
||||
-H "Authorization: token $RELEASE_TOKEN")
|
||||
|
||||
RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..."
|
||||
else
|
||||
echo "No existing release found, creating $TAG..."
|
||||
RESPONSE=$(curl -s -X POST "$API/releases" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create release. API response:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release created with id $RELEASE_ID."
|
||||
fi
|
||||
|
||||
curl -s -X POST "$API/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk"
|
||||
|
||||
echo "Done — $TAG is live at:"
|
||||
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
|
||||
APK: build/app/outputs/flutter-apk/app-release.apk
|
||||
run: bash scripts/publish_apk_release.sh
|
||||
|
||||
@@ -49,3 +49,4 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
.superpowers/
|
||||
|
||||
@@ -6,11 +6,16 @@ Native Android client for FabledAssistant, a self-hosted AI second-brain and pro
|
||||
|
||||
- **Daily Briefing** — the primary screen; opens on launch. Shows your AI-compiled morning digest (tasks, calendar, weather, RSS) with a full conversation you can reply to inline. Refresh manually or browse past briefings from the overflow menu.
|
||||
- **Quick Capture** — always-visible input bar above all tabs. Type a note or task and submit; multiple captures queue sequentially so the input is never blocked. Falls back to offline persistence when the server is unreachable.
|
||||
- **Library** — unified browsable list of notes, tasks, and projects with filter pills (All · Notes · Tasks · Projects). Tasks have a secondary status sub-filter and a tappable status icon to cycle todo → in progress → done without opening the editor. Inline search filters results live.
|
||||
- **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one.
|
||||
- **Knowledge** — unified browsable feed of notes, people, places, lists, and tasks across six filter tabs. Tag filters, inline search (debounced), and two-tier pagination (ID list → batch hydration). Tasks load from `/api/tasks` directly and are fully integrated with the knowledge feed.
|
||||
- **Chat** — streaming AI conversations with real-time SSE display, including live tool-use status notifications (e.g. "Calling create_note…"). Tap + to start a new conversation or open an existing one.
|
||||
- **Calendar** — month strip + daily agenda view backed by the server's internal event store. Full event CRUD with a modal form: title, all-day toggle, start/end date+time pickers, repeat (None/Daily/Weekly/Monthly/Yearly), description, location, and colour chips. Custom RRULE strings are preserved read-only.
|
||||
- **News** — RSS article feed with per-feed filtering and reactions. Tap "Discuss" to open any article in a new chat conversation.
|
||||
- **Projects** — browse and edit projects; tap a project to see its milestone-grouped task list.
|
||||
- **Voice I/O** — tap the microphone in the capture bar or chat to dictate. Server-side STT transcribes audio; TTS reads assistant replies aloud in voice mode.
|
||||
- **Note & task editing** — full Markdown editor for notes, task editor with due date, priority, project, and milestone assignment.
|
||||
- **OAuth / SSO** — authenticates via your server's OIDC provider; local username/password login also supported if enabled server-side.
|
||||
- **Session persistence** — stays logged in across restarts via a persistent cookie jar.
|
||||
- **Auto-refresh** — data refreshes automatically when the app returns to the foreground (throttled to once per 5 minutes) and when switching between shell tabs.
|
||||
- **Auto-update** — checks your Forgejo releases on launch and prompts to download and install new APKs in-app.
|
||||
|
||||
## Requirements
|
||||
@@ -39,33 +44,50 @@ On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.exa
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart # Entry point; resolves async deps before runApp
|
||||
├── app.dart # GoRouter + auth redirect guards + 3-tab shell
|
||||
├── main.dart # Entry point; resolves async deps before runApp
|
||||
├── app.dart # GoRouter + auth guards + 3-tab shell + auto-refresh
|
||||
├── core/
|
||||
│ ├── constants.dart # Route name constants
|
||||
│ ├── exceptions.dart # AppException hierarchy
|
||||
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
|
||||
│ ├── constants.dart # Route name constants
|
||||
│ ├── exceptions.dart # AppException hierarchy
|
||||
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
|
||||
├── data/
|
||||
│ ├── api/ # Dio HTTP layer (one class per resource)
|
||||
│ ├── models/ # Plain Dart models with fromJson/toJson
|
||||
│ └── repositories/ # Thin wrappers over API classes
|
||||
├── providers/ # Riverpod providers (state + dependency wiring)
|
||||
│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
|
||||
│ ├── api/ # Dio HTTP layer (one class per resource)
|
||||
│ │ ├── chat_api.dart # SSE streaming; typed ChatStreamEvent (text/status)
|
||||
│ │ ├── events_api.dart # Calendar event CRUD
|
||||
│ │ ├── knowledge_api.dart # Two-tier paginated knowledge feed
|
||||
│ │ ├── news_api.dart # RSS feed + reactions
|
||||
│ │ └── voice_api.dart # STT + TTS endpoints
|
||||
│ ├── models/ # Plain Dart models with fromJson/toJson
|
||||
│ │ ├── calendar_event.dart # CalendarEvent + dateOnly() helper
|
||||
│ │ ├── knowledge_item.dart # KnowledgeItem (notes/tasks unified)
|
||||
│ │ └── message.dart # Chat message with streaming status
|
||||
│ └── repositories/ # Thin wrappers over API classes
|
||||
├── providers/ # Riverpod providers (state + dependency wiring)
|
||||
│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
|
||||
│ ├── calendar_provider.dart # CalendarNotifier: month navigation + event CRUD
|
||||
│ ├── chat_provider.dart # Conversations + streaming messages + status
|
||||
│ ├── knowledge_provider.dart # Two-tier pagination; delegates tasks to TasksApi
|
||||
│ ├── news_provider.dart # NewsNotifier + FeedsNotifier
|
||||
│ └── capture_work_queue_provider.dart # Sequential in-memory capture queue
|
||||
├── screens/
|
||||
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
|
||||
│ ├── library/ # LibraryScreen (unified notes/tasks/projects)
|
||||
│ ├── chat/ # ConversationsTabScreen + ChatScreen
|
||||
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
|
||||
│ ├── tasks/ # TaskEditScreen
|
||||
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
|
||||
│ ├── calendar/ # CalendarScreen (TableCalendar) + EventFormSheet
|
||||
│ ├── chat/ # ConversationsTabScreen + ChatScreen
|
||||
│ ├── knowledge/ # KnowledgeScreen (6-tab feed + search + tag filters)
|
||||
│ ├── library/ # ProjectTasksScreen (milestone-grouped task list)
|
||||
│ ├── news/ # NewsScreen (feed filter + reactions + discuss)
|
||||
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
|
||||
│ ├── projects/ # ProjectsScreen + ProjectEditScreen
|
||||
│ ├── tasks/ # TaskEditScreen
|
||||
│ └── settings/ auth/ setup/ splash/
|
||||
└── widgets/
|
||||
├── chat_message_bubble.dart # Shared bubble (used by Chat + Briefing)
|
||||
├── briefing_digest_card.dart # Expandable first-message card
|
||||
└── library_item_card.dart # Note / task / project row widgets
|
||||
├── chat_message_bubble.dart # Shared bubble (Chat + Briefing); shows tool status
|
||||
├── knowledge_item_card.dart # Card for notes, people, places, lists, tasks
|
||||
├── news_card.dart # RSS article card with reactions
|
||||
└── voice_mic_button.dart # Animated mic button (capture bar + chat)
|
||||
```
|
||||
|
||||
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`
|
||||
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`, `table_calendar`, `record`, `just_audio`
|
||||
|
||||
## Building a Release APK
|
||||
|
||||
@@ -91,7 +113,7 @@ The build job has `needs: [analyze]` — a failed analyze or test blocks the APK
|
||||
To cut a release:
|
||||
|
||||
```bash
|
||||
git tag v26.03.11 && git push origin v26.03.11
|
||||
git tag v26.04.06 && git push origin v26.04.06
|
||||
```
|
||||
|
||||
Requires a `RELEASE_TOKEN` secret (Forgejo PAT with `write:repository` scope) set in repo Settings → Secrets → Actions.
|
||||
|
||||
@@ -10,3 +10,4 @@ GeneratedPluginRegistrant.java
|
||||
# Signing secrets — never commit these
|
||||
key.properties
|
||||
fabled-release-key.jks
|
||||
app/release.jks
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,521 @@
|
||||
# Android Nav Restructure & Projects Staleness Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the Projects bottom-nav tab with a "More" bottom sheet giving access to Projects, News, and Calendar; fix stale task data when returning from task edit.
|
||||
|
||||
**Architecture:** The shell's `_tabs` list shrinks from 4 to 3 entries; the 4th nav destination ("More") is intercepted in `onDestinationSelected` to show a `showModalBottomSheet` instead of navigating. Projects moves from a ShellRoute to a top-level push route. News and Calendar are added as stub push-routes. `_tabIndex` maps the three overflow paths to index 3 so "More" highlights correctly. The staleness fix moves the task-tap callback out of the stateless `_TaskRow` widget into the parent `ConsumerState` where `ref` is available, using `.then()` to invalidate after pop.
|
||||
|
||||
**Tech Stack:** Flutter, GoRouter, Riverpod, Material 3
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action | What changes |
|
||||
|------|--------|-------------|
|
||||
| `lib/core/constants.dart` | Modify | Add `news` and `calendar` route constants |
|
||||
| `lib/app.dart` | Modify | Remove Projects from ShellRoute, add 3 push routes, shrink `_tabs`, add `_showMoreSheet`, update `_tabIndex`, update both nav widgets |
|
||||
| `lib/screens/library/project_tasks_screen.dart` | Modify | Add `onTap: VoidCallback` to `_TaskRow`; add `_openTask` method to state; pass callback at both call sites |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add route constants
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/core/constants.dart`
|
||||
|
||||
- [ ] **Step 1: Add `news` and `calendar` to `Routes`**
|
||||
|
||||
Open `lib/core/constants.dart`. The file currently ends with:
|
||||
```dart
|
||||
abstract class Routes {
|
||||
static const splash = '/';
|
||||
static const setup = '/setup';
|
||||
static const login = '/login';
|
||||
static const notes = '/notes';
|
||||
static const noteDetail = '/notes/:id';
|
||||
static const noteEdit = '/notes/:id/edit';
|
||||
static const noteNew = '/notes/new';
|
||||
static const tasks = '/tasks';
|
||||
static const taskNew = '/tasks/new';
|
||||
static const taskEdit = '/tasks/:id/edit';
|
||||
static const knowledge = '/knowledge';
|
||||
static const projects = '/projects';
|
||||
static const projectEdit = '/projects/:id/edit';
|
||||
static const conversations = '/chat';
|
||||
static const chat = '/chat/:id';
|
||||
static const quickCapture = '/quick-capture';
|
||||
static const settings = '/settings';
|
||||
static const briefing = '/briefing';
|
||||
static const projectTasks = '/projects/:id/tasks';
|
||||
}
|
||||
```
|
||||
|
||||
Add two constants after `briefing`:
|
||||
```dart
|
||||
abstract class Routes {
|
||||
static const splash = '/';
|
||||
static const setup = '/setup';
|
||||
static const login = '/login';
|
||||
static const notes = '/notes';
|
||||
static const noteDetail = '/notes/:id';
|
||||
static const noteEdit = '/notes/:id/edit';
|
||||
static const noteNew = '/notes/new';
|
||||
static const tasks = '/tasks';
|
||||
static const taskNew = '/tasks/new';
|
||||
static const taskEdit = '/tasks/:id/edit';
|
||||
static const knowledge = '/knowledge';
|
||||
static const projects = '/projects';
|
||||
static const projectEdit = '/projects/:id/edit';
|
||||
static const conversations = '/chat';
|
||||
static const chat = '/chat/:id';
|
||||
static const quickCapture = '/quick-capture';
|
||||
static const settings = '/settings';
|
||||
static const briefing = '/briefing';
|
||||
static const news = '/news';
|
||||
static const calendar = '/calendar';
|
||||
static const projectTasks = '/projects/:id/tasks';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify no analysis errors**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/core/constants.dart
|
||||
```
|
||||
Expected: `No issues found!`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Restructure shell and add routes in `app.dart`
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/app.dart`
|
||||
|
||||
This task has several sub-steps. Make them all before running analyze.
|
||||
|
||||
- [ ] **Step 1: Move Projects out of ShellRoute, add three push routes**
|
||||
|
||||
Find the `ShellRoute` block (currently lines ~142–162):
|
||||
```dart
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => _Shell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.briefing,
|
||||
builder: (_, _) => const BriefingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.knowledge,
|
||||
builder: (_, _) => const KnowledgeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.conversations,
|
||||
builder: (_, _) => const ConversationsTabScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projects,
|
||||
builder: (_, _) => const ProjectsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
Replace with (Projects removed from shell; three new top-level routes added after the closing `],` of the ShellRoute):
|
||||
```dart
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => _Shell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.briefing,
|
||||
builder: (_, _) => const BriefingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.knowledge,
|
||||
builder: (_, _) => const KnowledgeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.conversations,
|
||||
builder: (_, _) => const ConversationsTabScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projects,
|
||||
builder: (_, _) => const ProjectsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.news,
|
||||
builder: (_, _) => Scaffold(
|
||||
appBar: AppBar(title: const Text('News')),
|
||||
body: const Center(child: Text('News — coming soon')),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.calendar,
|
||||
builder: (_, _) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Calendar')),
|
||||
body: const Center(child: Text('Calendar — coming soon')),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Shrink `_tabs` from 4 to 3 entries**
|
||||
|
||||
Find in `_ShellState`:
|
||||
```dart
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
Routes.projects,
|
||||
];
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```dart
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `_tabIndex` to map overflow routes to index 3**
|
||||
|
||||
Find:
|
||||
```dart
|
||||
int _tabIndex(String location) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```dart
|
||||
int _tabIndex(String location) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
}
|
||||
if (location.startsWith(Routes.projects) ||
|
||||
location.startsWith(Routes.news) ||
|
||||
location.startsWith(Routes.calendar)) return 3;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `_showMoreSheet` method to `_ShellState`**
|
||||
|
||||
Add this method anywhere in `_ShellState`, e.g. just before `build`:
|
||||
```dart
|
||||
void _showMoreSheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: const Text('Projects'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.projects);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.newspaper_outlined),
|
||||
title: const Text('News'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.news);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.calendar_month_outlined),
|
||||
title: const Text('Calendar'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.calendar);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update `NavigationRail` — intercept index 3 and swap destination**
|
||||
|
||||
Find in the wide-layout branch:
|
||||
```dart
|
||||
NavigationRail(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: Text('Briefing'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: Text('Knowledge'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: Text('Chat'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.folder_outlined),
|
||||
selectedIcon: Icon(Icons.folder),
|
||||
label: Text('Projects'),
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```dart
|
||||
NavigationRail(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) {
|
||||
if (i == 3) {
|
||||
_showMoreSheet(context);
|
||||
} else {
|
||||
context.go(_tabs[i]);
|
||||
}
|
||||
},
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: Text('Briefing'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: Text('Knowledge'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: Text('Chat'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.more_horiz_outlined),
|
||||
selectedIcon: Icon(Icons.more_horiz),
|
||||
label: Text('More'),
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `NavigationBar` — intercept index 3 and swap destination**
|
||||
|
||||
Find in the narrow-layout branch:
|
||||
```dart
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: 'Briefing',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: 'Knowledge',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: 'Chat',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.folder_outlined),
|
||||
selectedIcon: Icon(Icons.folder),
|
||||
label: 'Projects',
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```dart
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) {
|
||||
if (i == 3) {
|
||||
_showMoreSheet(context);
|
||||
} else {
|
||||
context.go(_tabs[i]);
|
||||
}
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: 'Briefing',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: 'Knowledge',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: 'Chat',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.more_horiz_outlined),
|
||||
selectedIcon: Icon(Icons.more_horiz),
|
||||
label: 'More',
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Verify no analysis errors**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/app.dart lib/core/constants.dart
|
||||
```
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/core/constants.dart lib/app.dart
|
||||
git commit -m "feat: replace Projects tab with More bottom sheet (news/calendar stubs)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fix stale tasks after returning from task edit
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/screens/library/project_tasks_screen.dart`
|
||||
|
||||
- [ ] **Step 1: Add `_openTask` method to `_ProjectTasksScreenState`**
|
||||
|
||||
`_ProjectTasksScreenState` is a `ConsumerState` — it has `ref` and `context`. Find the class body (look for `_cycleStatus` method as a landmark) and add `_openTask` as a sibling method:
|
||||
|
||||
```dart
|
||||
void _openTask(int taskId) {
|
||||
context
|
||||
.push(Routes.taskEdit.replaceFirst(':id', '$taskId'))
|
||||
.then((_) => ref.invalidate(projectTasksProvider(widget.projectId)));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `onTap` parameter to `_TaskRow`**
|
||||
|
||||
Find the `_TaskRow` class definition:
|
||||
```dart
|
||||
class _TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
final TaskStatus effectiveStatus;
|
||||
final VoidCallback onStatusTap;
|
||||
|
||||
const _TaskRow({
|
||||
required this.task,
|
||||
required this.effectiveStatus,
|
||||
required this.onStatusTap,
|
||||
});
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```dart
|
||||
class _TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
final TaskStatus effectiveStatus;
|
||||
final VoidCallback onStatusTap;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TaskRow({
|
||||
required this.task,
|
||||
required this.effectiveStatus,
|
||||
required this.onStatusTap,
|
||||
required this.onTap,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use the `onTap` callback in `InkWell`**
|
||||
|
||||
Find inside `_TaskRow.build`:
|
||||
```dart
|
||||
child: InkWell(
|
||||
onTap: () => context
|
||||
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```dart
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Pass `onTap` at both `_TaskRow` call sites**
|
||||
|
||||
There are two places in `_buildBody` where `_TaskRow` is instantiated. Update both:
|
||||
|
||||
**First call site (milestone tasks, around line 170):**
|
||||
```dart
|
||||
return _TaskRow(
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
onTap: () => _openTask(task.id),
|
||||
);
|
||||
```
|
||||
|
||||
**Second call site (unassigned tasks, around line 197):**
|
||||
```dart
|
||||
return _TaskRow(
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
onTap: () => _openTask(task.id),
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify no analysis errors**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/screens/library/project_tasks_screen.dart
|
||||
```
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/screens/library/project_tasks_screen.dart
|
||||
git commit -m "fix: invalidate project tasks on return from task edit screen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Final check
|
||||
|
||||
- [ ] **Step 1: Full analyze**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze
|
||||
```
|
||||
Expected: `No issues found!`
|
||||
@@ -0,0 +1,823 @@
|
||||
# Android News Screen Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the Android News screen — a paginated, feed-filtered list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
|
||||
|
||||
**Architecture:** `NewsNotifier` (`AsyncNotifier<NewsState>`) holds the accumulated item list, pagination, reaction map, and selected feed. The screen is a `ConsumerStatefulWidget` at `/news`. Discuss calls `POST /api/chat/from-article/{id}` (same endpoint as web) and navigates to the returned conversation. Reactions reuse `briefingApiProvider`. The existing `NewsCard` widget is used unchanged via a `RssItemMeta.fromNewsItem` adapter factory.
|
||||
|
||||
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|---------------|
|
||||
| `lib/data/models/news_item.dart` | Create | `NewsItem` model + `fromJson` |
|
||||
| `lib/data/models/briefing_feed.dart` | Create | `BriefingFeed` model + `fromJson` |
|
||||
| `lib/data/api/news_api.dart` | Create | `getNewsItems(...)` + `getFeeds()` Dio calls |
|
||||
| `lib/data/api/chat_api.dart` | Modify | Add `openArticleInChat(int itemId)` |
|
||||
| `lib/providers/api_client_provider.dart` | Modify | Add `newsApiProvider` |
|
||||
| `lib/widgets/news_card.dart` | Modify | Add `RssItemMeta.fromNewsItem` factory |
|
||||
| `lib/providers/news_provider.dart` | Create | `NewsState`, `NewsNotifier`, `newsProvider`, `feedsProvider` |
|
||||
| `lib/screens/news/news_screen.dart` | Create | News screen UI |
|
||||
| `lib/app.dart` | Modify | Replace News stub route with `NewsScreen()` |
|
||||
| `test/widget_test.dart` | Modify | Add `NewsItem.fromJson` and `BriefingFeed.fromJson` tests |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Data models + tests
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/data/models/news_item.dart`
|
||||
- Create: `lib/data/models/briefing_feed.dart`
|
||||
- Modify: `test/widget_test.dart`
|
||||
|
||||
- [ ] **Step 1: Create `NewsItem` model**
|
||||
|
||||
Create `lib/data/models/news_item.dart`:
|
||||
|
||||
```dart
|
||||
class NewsItem {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String snippet;
|
||||
final String source;
|
||||
final DateTime? publishedAt;
|
||||
final List<String> topics;
|
||||
final String? reaction;
|
||||
|
||||
const NewsItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.snippet,
|
||||
required this.source,
|
||||
this.publishedAt,
|
||||
required this.topics,
|
||||
this.reaction,
|
||||
});
|
||||
|
||||
factory NewsItem.fromJson(Map<String, dynamic> json) => NewsItem(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
snippet: json['snippet'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
publishedAt: json['published_at'] != null
|
||||
? DateTime.tryParse(json['published_at'] as String)
|
||||
: null,
|
||||
topics: (json['topics'] as List<dynamic>?)
|
||||
?.cast<String>()
|
||||
.toList() ??
|
||||
[],
|
||||
reaction: json['reaction'] as String?,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `BriefingFeed` model**
|
||||
|
||||
Create `lib/data/models/briefing_feed.dart`:
|
||||
|
||||
```dart
|
||||
class BriefingFeed {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String? category;
|
||||
|
||||
const BriefingFeed({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
this.category,
|
||||
});
|
||||
|
||||
factory BriefingFeed.fromJson(Map<String, dynamic> json) => BriefingFeed(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
category: json['category'] as String?,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write model tests**
|
||||
|
||||
Add these groups to `test/widget_test.dart` (before the closing `}`):
|
||||
|
||||
```dart
|
||||
group('NewsItem.fromJson', () {
|
||||
test('parses all fields', () {
|
||||
final json = {
|
||||
'id': 42,
|
||||
'title': 'Big news',
|
||||
'url': 'https://example.com/article',
|
||||
'snippet': 'A short summary.',
|
||||
'source': 'Example News',
|
||||
'published_at': '2026-01-15T10:00:00',
|
||||
'topics': ['tech', 'ai'],
|
||||
'reaction': 'up',
|
||||
};
|
||||
final item = NewsItem.fromJson(json);
|
||||
expect(item.id, equals(42));
|
||||
expect(item.title, equals('Big news'));
|
||||
expect(item.url, equals('https://example.com/article'));
|
||||
expect(item.snippet, equals('A short summary.'));
|
||||
expect(item.source, equals('Example News'));
|
||||
expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00')));
|
||||
expect(item.topics, equals(['tech', 'ai']));
|
||||
expect(item.reaction, equals('up'));
|
||||
});
|
||||
|
||||
test('handles null published_at and reaction', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'title': '',
|
||||
'url': '',
|
||||
'snippet': '',
|
||||
'source': '',
|
||||
'published_at': null,
|
||||
'topics': <dynamic>[],
|
||||
'reaction': null,
|
||||
};
|
||||
final item = NewsItem.fromJson(json);
|
||||
expect(item.publishedAt, isNull);
|
||||
expect(item.reaction, isNull);
|
||||
expect(item.topics, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('BriefingFeed.fromJson', () {
|
||||
test('parses all fields', () {
|
||||
final json = {
|
||||
'id': 7,
|
||||
'title': 'Hacker News',
|
||||
'url': 'https://news.ycombinator.com/rss',
|
||||
'category': 'tech',
|
||||
};
|
||||
final feed = BriefingFeed.fromJson(json);
|
||||
expect(feed.id, equals(7));
|
||||
expect(feed.title, equals('Hacker News'));
|
||||
expect(feed.url, equals('https://news.ycombinator.com/rss'));
|
||||
expect(feed.category, equals('tech'));
|
||||
});
|
||||
|
||||
test('handles null category', () {
|
||||
final json = {
|
||||
'id': 8,
|
||||
'title': 'Feed',
|
||||
'url': 'https://example.com/rss',
|
||||
'category': null,
|
||||
};
|
||||
final feed = BriefingFeed.fromJson(json);
|
||||
expect(feed.category, isNull);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Also add these imports at the top of `test/widget_test.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:fabled_app/data/models/news_item.dart';
|
||||
import 'package:fabled_app/data/models/briefing_feed.dart';
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter test
|
||||
```
|
||||
|
||||
Expected: All tests passed (17 total — 15 existing + 2 new groups = 4 new tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/data/models/news_item.dart lib/data/models/briefing_feed.dart test/widget_test.dart
|
||||
git commit -m "feat: add NewsItem and BriefingFeed models with tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: API layer
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/data/api/news_api.dart`
|
||||
- Modify: `lib/data/api/chat_api.dart`
|
||||
- Modify: `lib/providers/api_client_provider.dart`
|
||||
|
||||
- [ ] **Step 1: Create `NewsApi`**
|
||||
|
||||
Create `lib/data/api/news_api.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/briefing_feed.dart';
|
||||
import '../models/news_item.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class NewsApi {
|
||||
final Dio _dio;
|
||||
const NewsApi(this._dio);
|
||||
|
||||
/// GET /api/briefing/news
|
||||
/// Returns up to [limit] items starting at [offset], optionally filtered by [feedId].
|
||||
Future<List<NewsItem>> getNewsItems({
|
||||
int days = 90,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
int? feedId,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'days': days,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
if (feedId != null) 'feed_id': feedId,
|
||||
};
|
||||
final response = await _dio.get(
|
||||
'/api/briefing/news',
|
||||
queryParameters: params,
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['items'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/feeds
|
||||
Future<List<BriefingFeed>> getFeeds() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/feeds');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `openArticleInChat` to `ChatApi`**
|
||||
|
||||
In `lib/data/api/chat_api.dart`, add this method at the end of the `ChatApi` class (before the closing `}`):
|
||||
|
||||
```dart
|
||||
/// POST /api/chat/from-article/{itemId}
|
||||
/// Creates or retrieves a chat conversation seeded with the article.
|
||||
/// Returns the conversation_id.
|
||||
Future<int> openArticleInChat(int itemId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['conversation_id'] as int;
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `newsApiProvider` to `api_client_provider.dart`**
|
||||
|
||||
In `lib/providers/api_client_provider.dart`, add the import and provider.
|
||||
|
||||
Add import after the existing API imports (e.g., after `voice_api.dart`):
|
||||
```dart
|
||||
import '../data/api/news_api.dart';
|
||||
```
|
||||
|
||||
Add provider after `voiceRepositoryProvider`:
|
||||
```dart
|
||||
final newsApiProvider = Provider<NewsApi>((ref) {
|
||||
return NewsApi(ref.watch(dioProvider));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart
|
||||
```
|
||||
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart
|
||||
git commit -m "feat: add NewsApi, openArticleInChat, and newsApiProvider"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RssItemMeta adapter
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/widgets/news_card.dart`
|
||||
|
||||
`NewsCard` renders `RssItemMeta` objects. Rather than modifying the widget, we add a factory on `RssItemMeta` that converts a `NewsItem`.
|
||||
|
||||
- [ ] **Step 1: Add `fromNewsItem` factory**
|
||||
|
||||
In `lib/widgets/news_card.dart`, add this import at the top:
|
||||
|
||||
```dart
|
||||
import '../data/models/news_item.dart';
|
||||
```
|
||||
|
||||
Then add this factory inside the `RssItemMeta` class, after the existing `fromJson` factory:
|
||||
|
||||
```dart
|
||||
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
source: item.source,
|
||||
snippet: item.snippet,
|
||||
publishedAt: item.publishedAt,
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/widgets/news_card.dart
|
||||
```
|
||||
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/widgets/news_card.dart
|
||||
git commit -m "feat: add RssItemMeta.fromNewsItem adapter factory"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Providers
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/providers/news_provider.dart`
|
||||
|
||||
- [ ] **Step 1: Create `news_provider.dart`**
|
||||
|
||||
Create `lib/providers/news_provider.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/briefing_feed.dart';
|
||||
import '../data/models/news_item.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
// ─── NewsState ────────────────────────────────────────────────────────────────
|
||||
|
||||
class NewsState {
|
||||
final List<NewsItem> items;
|
||||
final int offset;
|
||||
final bool hasMore;
|
||||
final bool loadingMore;
|
||||
final int? selectedFeedId;
|
||||
final Map<int, String?> reactions;
|
||||
|
||||
const NewsState({
|
||||
required this.items,
|
||||
required this.offset,
|
||||
required this.hasMore,
|
||||
required this.loadingMore,
|
||||
required this.selectedFeedId,
|
||||
required this.reactions,
|
||||
});
|
||||
|
||||
NewsState copyWith({
|
||||
List<NewsItem>? items,
|
||||
int? offset,
|
||||
bool? hasMore,
|
||||
bool? loadingMore,
|
||||
Object? selectedFeedId = _sentinel,
|
||||
Map<int, String?>? reactions,
|
||||
}) {
|
||||
return NewsState(
|
||||
items: items ?? this.items,
|
||||
offset: offset ?? this.offset,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
selectedFeedId: selectedFeedId == _sentinel
|
||||
? this.selectedFeedId
|
||||
: selectedFeedId as int?,
|
||||
reactions: reactions ?? this.reactions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _sentinel = Object();
|
||||
|
||||
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
|
||||
|
||||
final newsProvider =
|
||||
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
|
||||
|
||||
class NewsNotifier extends AsyncNotifier<NewsState> {
|
||||
static const _limit = 40;
|
||||
|
||||
@override
|
||||
Future<NewsState> build() async {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
);
|
||||
return NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: null,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.loadingMore || !current.hasMore) return;
|
||||
state = AsyncData(current.copyWith(loadingMore: true));
|
||||
try {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: current.offset,
|
||||
feedId: current.selectedFeedId,
|
||||
);
|
||||
final updatedReactions = Map<int, String?>.from(current.reactions);
|
||||
for (final item in items) {
|
||||
updatedReactions.putIfAbsent(item.id, () => item.reaction);
|
||||
}
|
||||
state = AsyncData(current.copyWith(
|
||||
items: [...current.items, ...items],
|
||||
offset: current.offset + items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
reactions: updatedReactions,
|
||||
));
|
||||
} catch (e) {
|
||||
state = AsyncData(current.copyWith(loadingMore: false));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setFeed(int? feedId) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
feedId: feedId,
|
||||
);
|
||||
state = AsyncData(NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: feedId,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleReaction(int itemId, String reaction) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final prev = current.reactions[itemId];
|
||||
final next = prev == reaction ? null : reaction;
|
||||
state = AsyncData(current.copyWith(
|
||||
reactions: {...current.reactions, itemId: next},
|
||||
));
|
||||
final briefingApi = ref.read(briefingApiProvider);
|
||||
final future = next == null
|
||||
? briefingApi.deleteRssReaction(itemId)
|
||||
: briefingApi.postRssReaction(itemId, next);
|
||||
future.catchError((_) {
|
||||
final s = state.value;
|
||||
if (s != null) {
|
||||
state = AsyncData(s.copyWith(
|
||||
reactions: {...s.reactions, itemId: prev},
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
|
||||
|
||||
final feedsProvider =
|
||||
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
|
||||
|
||||
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
|
||||
@override
|
||||
Future<List<BriefingFeed>> build() async {
|
||||
return ref.read(newsApiProvider).getFeeds();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/providers/news_provider.dart
|
||||
```
|
||||
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/providers/news_provider.dart
|
||||
git commit -m "feat: add NewsNotifier and feedsProvider"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: News screen
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/screens/news/news_screen.dart`
|
||||
|
||||
- [ ] **Step 1: Create the screen**
|
||||
|
||||
Create `lib/screens/news/news_screen.dart`:
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../data/models/briefing_feed.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/news_provider.dart';
|
||||
import '../../widgets/news_card.dart';
|
||||
|
||||
class NewsScreen extends ConsumerStatefulWidget {
|
||||
const NewsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NewsScreen> createState() => _NewsScreenState();
|
||||
}
|
||||
|
||||
class _NewsScreenState extends ConsumerState<NewsScreen> {
|
||||
final Set<int> _openingChat = {};
|
||||
|
||||
Future<void> _handleDiscuss(int itemId) async {
|
||||
if (_openingChat.contains(itemId)) return;
|
||||
setState(() => _openingChat.add(itemId));
|
||||
try {
|
||||
final conversationId =
|
||||
await ref.read(chatApiProvider).openArticleInChat(itemId);
|
||||
if (mounted) {
|
||||
context.push(Routes.chat.replaceFirst(':id', '$conversationId'));
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to open article in chat.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _openingChat.remove(itemId));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
try {
|
||||
await ref.read(newsProvider.notifier).loadMore();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to load more articles.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final newsAsync = ref.watch(newsProvider);
|
||||
final feedsAsync = ref.watch(feedsProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('News', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
'Last 90 days',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: newsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Could not load news."),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(newsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (news) => Column(
|
||||
children: [
|
||||
_FeedFilter(
|
||||
feeds: feedsAsync.value ?? [],
|
||||
selectedFeedId: news.selectedFeedId,
|
||||
onChanged: (feedId) =>
|
||||
ref.read(newsProvider.notifier).setFeed(feedId),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
itemCount: news.items.length + 1,
|
||||
itemBuilder: (_, i) {
|
||||
if (i == news.items.length) {
|
||||
if (!news.hasMore) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(
|
||||
child: news.loadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: FilledButton.tonal(
|
||||
onPressed: _loadMore,
|
||||
child: const Text('Load more'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final item = news.items[i];
|
||||
return NewsCard(
|
||||
item: RssItemMeta.fromNewsItem(item),
|
||||
reaction: news.reactions[item.id],
|
||||
onReaction: (itemId, reaction) => ref
|
||||
.read(newsProvider.notifier)
|
||||
.toggleReaction(itemId, reaction),
|
||||
onDiscuss: _openingChat.contains(item.id)
|
||||
? null
|
||||
: () => _handleDiscuss(item.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedFilter extends StatelessWidget {
|
||||
final List<BriefingFeed> feeds;
|
||||
final int? selectedFeedId;
|
||||
final void Function(int? feedId) onChanged;
|
||||
|
||||
const _FeedFilter({
|
||||
required this.feeds,
|
||||
required this.selectedFeedId,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Feed:',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DropdownButton<int?>(
|
||||
value: selectedFeedId,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('All feeds'),
|
||||
),
|
||||
...feeds.map(
|
||||
(f) => DropdownMenuItem<int?>(
|
||||
value: f.id,
|
||||
child: Text(f.title),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => onChanged(v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/screens/news/news_screen.dart
|
||||
```
|
||||
|
||||
Expected: `No issues found!`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/screens/news/news_screen.dart
|
||||
git commit -m "feat: add NewsScreen with feed filter, reactions, and discuss"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire route and final verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/app.dart`
|
||||
|
||||
- [ ] **Step 1: Replace the News stub route with `NewsScreen`**
|
||||
|
||||
In `lib/app.dart`, add the import near the other screen imports:
|
||||
|
||||
```dart
|
||||
import 'screens/news/news_screen.dart';
|
||||
```
|
||||
|
||||
Find and replace the stub route:
|
||||
|
||||
```dart
|
||||
GoRoute(
|
||||
path: Routes.news,
|
||||
builder: (_, _) => Scaffold(
|
||||
appBar: AppBar(title: const Text('News')),
|
||||
body: const Center(child: Text('News — coming soon')),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```dart
|
||||
GoRoute(
|
||||
path: Routes.news,
|
||||
builder: (_, _) => const NewsScreen(),
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Full analyze and tests**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
Expected: `No issues found!` and all tests passed.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/app.dart
|
||||
git commit -m "feat: wire News route to NewsScreen"
|
||||
```
|
||||
@@ -0,0 +1,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)
|
||||
@@ -0,0 +1,199 @@
|
||||
# Android Calendar Screen Design
|
||||
|
||||
**Goal:** Build the Android Calendar screen — a month-strip + daily agenda view with full event CRUD (create, edit, delete) including a simple recurrence picker.
|
||||
|
||||
**Architecture:** `CalendarNotifier` (`AsyncNotifier<CalendarState>`) holds a `Map<DateTime, List<CalendarEvent>>` keyed by date-only values, selected day, focused month, and loaded date range. The screen uses `table_calendar` for the month strip and a `ListView` for the daily agenda. Create/edit/delete is handled by `EventFormSheet`, a scrollable modal bottom sheet. The existing `/api/events` backend is used unchanged.
|
||||
|
||||
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, `table_calendar` package
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
### New
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `lib/data/models/calendar_event.dart` | `CalendarEvent` model + `fromJson` |
|
||||
| `lib/data/api/events_api.dart` | `getEvents`, `createEvent`, `updateEvent`, `deleteEvent` Dio calls |
|
||||
| `lib/providers/calendar_provider.dart` | `CalendarState`, `CalendarNotifier`, `calendarProvider` |
|
||||
| `lib/screens/calendar/calendar_screen.dart` | Calendar screen UI (month strip + agenda) |
|
||||
| `lib/screens/calendar/event_form_sheet.dart` | Create/edit modal bottom sheet |
|
||||
|
||||
### Modified
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `lib/providers/api_client_provider.dart` | Add `eventsApiProvider` |
|
||||
| `lib/app.dart` | Replace Calendar stub route with `CalendarScreen()` |
|
||||
| `test/widget_test.dart` | Add `CalendarEvent.fromJson` tests |
|
||||
| `pubspec.yaml` | Add `table_calendar` dependency |
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### `CalendarEvent`
|
||||
```dart
|
||||
class CalendarEvent {
|
||||
final int id;
|
||||
final String title;
|
||||
final DateTime startDt;
|
||||
final DateTime? endDt;
|
||||
final bool allDay;
|
||||
final String description;
|
||||
final String location;
|
||||
final String color;
|
||||
final String? recurrence; // raw RRULE string, e.g. "FREQ=WEEKLY"
|
||||
final int? projectId;
|
||||
final int? reminderMinutes;
|
||||
}
|
||||
```
|
||||
|
||||
`fromJson` maps: `id`, `title`, `start_dt` / `end_dt` (ISO string → `DateTime.parse`), `all_day`, `description`, `location`, `color`, `recurrence` (nullable), `project_id` (nullable), `reminder_minutes` (nullable).
|
||||
|
||||
Date normalization helper — used throughout to build map keys:
|
||||
```dart
|
||||
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Layer
|
||||
|
||||
### `events_api.dart`
|
||||
|
||||
```dart
|
||||
class EventsApi {
|
||||
final Dio _dio;
|
||||
const EventsApi(this._dio);
|
||||
|
||||
// GET /api/events?from=<iso>&to=<iso>
|
||||
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async { ... }
|
||||
|
||||
// POST /api/events
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async { ... }
|
||||
|
||||
// PATCH /api/events/{id}
|
||||
Future<CalendarEvent> updateEvent(int id, Map<String, dynamic> fields) async { ... }
|
||||
|
||||
// DELETE /api/events/{id}
|
||||
Future<void> deleteEvent(int id) async { ... }
|
||||
}
|
||||
```
|
||||
|
||||
All methods catch `DioException` and rethrow via `dioToApp(e)` (same pattern as `NewsApi`).
|
||||
|
||||
### `api_client_provider.dart` addition
|
||||
```dart
|
||||
final eventsApiProvider = Provider<EventsApi>((ref) =>
|
||||
EventsApi(ref.watch(dioProvider)));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### `CalendarState`
|
||||
```dart
|
||||
class CalendarState {
|
||||
final Map<DateTime, List<CalendarEvent>> eventsByDay; // keys: midnight local time
|
||||
final DateTime selectedDay;
|
||||
final DateTime focusedMonth;
|
||||
final DateTimeRange loadedRange;
|
||||
}
|
||||
```
|
||||
|
||||
### `CalendarNotifier extends AsyncNotifier<CalendarState>`
|
||||
|
||||
- **`build()`**: fetches events for `[firstDayOfMonth - 1 month, lastDayOfMonth + 1 month]` for the current month; populates `eventsByDay`; sets `selectedDay` to today; sets `loadedRange` to the fetched range.
|
||||
|
||||
- **`selectDay(DateTime day)`**: synchronous state update — updates `selectedDay` and `focusedMonth` to `DateTime(day.year, day.month)`. No API call.
|
||||
|
||||
- **`loadMonth(DateTime month)`**: updates `focusedMonth`. If `month` is already within `loadedRange`, no-op (state update only). Otherwise fetches events for that month, merges new items into `eventsByDay`, extends `loadedRange`.
|
||||
|
||||
- **`addEvent(CalendarEvent event)`**: inserts the event into `eventsByDay` under `dateOnly(event.startDt)`. Synchronous local mutation after successful API call.
|
||||
|
||||
- **`updateEvent(CalendarEvent updated)`**: removes old entry by `id` from its old date bucket (scanned), inserts under `dateOnly(updated.startDt)`. Synchronous local mutation.
|
||||
|
||||
- **`removeEvent(int id, DateTime date)`**: removes from `eventsByDay[dateOnly(date)]` by id. Synchronous local mutation.
|
||||
|
||||
All three mutation methods accept the server-returned `CalendarEvent` — the screen calls the API first, then passes the result to the notifier.
|
||||
|
||||
---
|
||||
|
||||
## Screen Behaviour
|
||||
|
||||
### `CalendarScreen` (`ConsumerStatefulWidget`)
|
||||
|
||||
**AppBar**: title "Calendar".
|
||||
|
||||
**Month strip** (`TableCalendar`):
|
||||
- Format: `CalendarFormat.month`
|
||||
- Selected day highlighted with primary color
|
||||
- Days with events show a dot indicator
|
||||
- `onDaySelected`: calls `notifier.selectDay(day)`
|
||||
- `onPageChanged`: calls `notifier.loadMonth(month)`
|
||||
|
||||
**Agenda list** (`ListView.builder`):
|
||||
- Items: `state.eventsByDay[dateOnly(state.selectedDay)] ?? []`
|
||||
- Each `EventTile` shows: color dot, title, time string ("All day" if `allDay`, otherwise formatted start time)
|
||||
- Tap → opens `EventFormSheet` in edit mode
|
||||
- Empty: centered "No events" message
|
||||
|
||||
**FAB** (`FloatingActionButton`): opens `EventFormSheet` in create mode with `startDt` pre-set to `selectedDay` at current time (rounded to nearest hour)
|
||||
|
||||
**Initial loading**: `CircularProgressIndicator` centered. Error: message + "Retry" button calls `ref.invalidate(calendarProvider)`.
|
||||
|
||||
---
|
||||
|
||||
## Event Form Sheet
|
||||
|
||||
### `EventFormSheet` (shown via `showModalBottomSheet(isScrollControlled: true, useSafeArea: true)`)
|
||||
|
||||
Accepts `CalendarEvent? event` (null = create mode) and `DateTime? initialDate` (used in create mode).
|
||||
|
||||
**Fields:**
|
||||
| Field | Widget | Notes |
|
||||
|-------|--------|-------|
|
||||
| Title | `TextField` | Required |
|
||||
| All-day | `SwitchListTile` | Hides time pickers when on |
|
||||
| Start date | `ListTile` → `showDatePicker` | |
|
||||
| Start time | `ListTile` → `showTimePicker` | Hidden when all-day |
|
||||
| End date | `ListTile` → `showDatePicker` | Optional, clearable |
|
||||
| End time | `ListTile` → `showTimePicker` | Hidden when all-day |
|
||||
| Repeat | `DropdownButton` | None / Daily / Weekly / Monthly / Yearly |
|
||||
| Description | `TextField` multiline | Optional |
|
||||
| Location | `TextField` | Optional |
|
||||
| Color | Row of `InkWell` color chips | 6 preset colors + clear (empty string) |
|
||||
|
||||
**Repeat → RRULE mapping:**
|
||||
| UI value | RRULE stored |
|
||||
|----------|-------------|
|
||||
| None | `null` |
|
||||
| Daily | `FREQ=DAILY` |
|
||||
| Weekly | `FREQ=WEEKLY` |
|
||||
| Monthly | `FREQ=MONTHLY` |
|
||||
| Yearly | `FREQ=YEARLY` |
|
||||
|
||||
Existing events with an unrecognized RRULE string (does not match the 5 patterns above) display "Custom (read-only)" and the dropdown is disabled. The raw RRULE is preserved unchanged on save.
|
||||
|
||||
**Save flow:**
|
||||
1. Validate title non-empty
|
||||
2. Build payload map from form state
|
||||
3. Call `EventsApi.createEvent` or `EventsApi.updateEvent`
|
||||
4. On success: call `notifier.addEvent(result)` or `notifier.updateEvent(result)`, pop sheet
|
||||
5. On error: show SnackBar "Failed to save event."
|
||||
|
||||
**Delete flow (edit mode only):**
|
||||
1. Show `AlertDialog` "Delete this event?"
|
||||
2. On confirm: call `EventsApi.deleteEvent(event.id)`
|
||||
3. On success: call `notifier.removeEvent(event.id, event.startDt)`, pop sheet
|
||||
4. On error: show SnackBar "Failed to delete event."
|
||||
|
||||
---
|
||||
|
||||
## What This Does NOT Include
|
||||
|
||||
- Recurrence instance editing ("edit this event only" vs "all events") — backend does not support this distinction
|
||||
- Reminder/notification editing — `reminder_minutes` field not exposed (backend stores it but no push notification is sent from events)
|
||||
- Project association — `project_id` field not exposed in the form
|
||||
- CalDAV sync trigger — available via `POST /api/events/sync` but not surfaced in this screen
|
||||
@@ -0,0 +1,136 @@
|
||||
# Android Nav Restructure & Projects Staleness Fix
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the Projects bottom-nav tab with a "More" bottom sheet that houses Projects, News, and Calendar; fix stale task data on the project tasks screen.
|
||||
|
||||
**Architecture:** The shell's 4th nav item becomes a non-routing action — tapping it shows a `showModalBottomSheet` with the three overflow destinations. `_tabIndex()` maps `/projects`, `/news`, `/calendar` prefixes to index 3 so the "More" tab highlights correctly. News and Calendar are added as stub push-routes now; their real screens are built in subsequent passes. The staleness fix is a single `await` + `ref.invalidate` after returning from task edit.
|
||||
|
||||
**Tech Stack:** Flutter, GoRouter, Riverpod, Material 3 `NavigationBar` / `NavigationRail`
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Two independent changes in one pass:
|
||||
|
||||
1. **Nav restructure** — `lib/app.dart`
|
||||
2. **Staleness fix** — `lib/screens/library/project_tasks_screen.dart`
|
||||
|
||||
---
|
||||
|
||||
## Design Details
|
||||
|
||||
### 1. Nav Restructure (`lib/app.dart`)
|
||||
|
||||
**Route changes:**
|
||||
- Remove `/projects` from the `ShellRoute` routes list.
|
||||
- Add three new top-level `GoRoute` entries (alongside the existing non-shell routes):
|
||||
- `/projects` → `ProjectsScreen()` (moved from shell)
|
||||
- `/news` → stub `Scaffold(body: Center(child: Text('News — coming soon')))`
|
||||
- `/calendar` → stub `Scaffold(body: Center(child: Text('Calendar — coming soon')))`
|
||||
- Add route constants to `lib/core/constants.dart`: `news = '/news'`, `calendar = '/calendar'`
|
||||
|
||||
**Shell tab list:**
|
||||
```dart
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
];
|
||||
```
|
||||
(3 entries — "More" is index 3 but handled specially, not a route)
|
||||
|
||||
**`_tabIndex()` update:**
|
||||
```dart
|
||||
int _tabIndex(String location) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
}
|
||||
// Projects, News, Calendar all highlight "More"
|
||||
if (location.startsWith(Routes.projects) ||
|
||||
location.startsWith(Routes.news) ||
|
||||
location.startsWith(Routes.calendar)) return 3;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
**`onDestinationSelected` update (both `NavigationBar` and `NavigationRail`):**
|
||||
```dart
|
||||
onDestinationSelected: (i) {
|
||||
if (i == 3) {
|
||||
_showMoreSheet(context);
|
||||
} else {
|
||||
context.go(_tabs[i]);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
**`_showMoreSheet` method on `_ShellState`:**
|
||||
```dart
|
||||
void _showMoreSheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: const Text('Projects'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.projects);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.newspaper_outlined),
|
||||
title: const Text('News'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.news);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.calendar_month_outlined),
|
||||
title: const Text('Calendar'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.calendar);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**4th nav destination label:** "More" with `Icons.more_horiz_outlined` / `Icons.more_horiz`.
|
||||
|
||||
**NavigationRail note:** The wide-layout rail also gets the same 4 destinations and the same intercept on `onDestinationSelected`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Projects Staleness Fix (`lib/screens/library/project_tasks_screen.dart`)
|
||||
|
||||
**Current (line ~321):**
|
||||
```dart
|
||||
context.push(Routes.taskEdit.replaceFirst(':id', '${task.id}'));
|
||||
```
|
||||
|
||||
**Fixed:**
|
||||
```dart
|
||||
await context.push(Routes.taskEdit.replaceFirst(':id', '${task.id}'));
|
||||
ref.invalidate(projectTasksProvider(widget.projectId));
|
||||
```
|
||||
|
||||
This re-fetches tasks as soon as the user pops back from the task edit screen, eliminating stale data.
|
||||
|
||||
---
|
||||
|
||||
## What This Does NOT Include
|
||||
|
||||
- Real News screen implementation (separate spec/pass)
|
||||
- Real Calendar screen implementation (separate spec/pass)
|
||||
- Any changes to the web frontend
|
||||
@@ -0,0 +1,177 @@
|
||||
# Android News Screen Design
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the Android News screen — a paginated, filterable list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
|
||||
|
||||
**Architecture:** A `NewsNotifier` (`AsyncNotifier`) holds the full accumulated item list, pagination state, and selected feed ID. The screen is a `ConsumerStatefulWidget` accessed via the More bottom sheet at `/news`. Discuss uses `POST /api/chat/from-article/{id}` (same endpoint as the web) so any backend behaviour change is automatically reflected. Reactions reuse the existing `briefingApiProvider` methods. The existing `NewsCard` widget is used without modification.
|
||||
|
||||
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
### New
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `lib/data/models/news_item.dart` | `NewsItem` model + `fromJson` |
|
||||
| `lib/data/models/briefing_feed.dart` | `BriefingFeed` model + `fromJson` |
|
||||
| `lib/data/api/news_api.dart` | `getNewsItems(...)` and `getFeeds()` API calls |
|
||||
| `lib/providers/news_provider.dart` | `NewsNotifier`, `NewsState`, `newsProvider`, `feedsProvider` |
|
||||
| `lib/screens/news/news_screen.dart` | News screen UI |
|
||||
|
||||
### Modified
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `lib/data/api/chat_api.dart` | Add `openArticleInChat(int itemId) → Future<int>` |
|
||||
| `lib/providers/api_client_provider.dart` | Add `newsApiProvider` |
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### `NewsItem`
|
||||
```dart
|
||||
class NewsItem {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String snippet;
|
||||
final String source;
|
||||
final DateTime? publishedAt;
|
||||
final List<String> topics;
|
||||
final String? reaction; // 'up' | 'down' | null
|
||||
}
|
||||
```
|
||||
`fromJson` maps: `id`, `title`, `url`, `snippet`, `source`, `published_at` (nullable ISO string → `DateTime.tryParse`), `topics` (cast `List<dynamic>` → `List<String>`), `reaction` (nullable string).
|
||||
|
||||
### `BriefingFeed`
|
||||
```dart
|
||||
class BriefingFeed {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String? category;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Layer
|
||||
|
||||
### `news_api.dart`
|
||||
|
||||
```dart
|
||||
class NewsApi {
|
||||
final Dio _dio;
|
||||
const NewsApi(this._dio);
|
||||
|
||||
// GET /api/briefing/news
|
||||
Future<NewsItemsResponse> getNewsItems({
|
||||
int days = 90,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
int? feedId,
|
||||
}) async { ... }
|
||||
|
||||
// GET /api/briefing/feeds
|
||||
Future<List<BriefingFeed>> getFeeds() async { ... }
|
||||
}
|
||||
|
||||
class NewsItemsResponse {
|
||||
final List<NewsItem> items;
|
||||
final int offset;
|
||||
final int limit;
|
||||
}
|
||||
```
|
||||
|
||||
### `chat_api.dart` addition
|
||||
```dart
|
||||
// POST /api/chat/from-article/{itemId}
|
||||
// Returns conversation_id
|
||||
Future<int> openArticleInChat(int itemId) async { ... }
|
||||
```
|
||||
|
||||
### `api_client_provider.dart` addition
|
||||
```dart
|
||||
final newsApiProvider = Provider<NewsApi>((ref) =>
|
||||
NewsApi(ref.watch(dioProvider)));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### `NewsState`
|
||||
```dart
|
||||
class NewsState {
|
||||
final List<NewsItem> items;
|
||||
final int offset;
|
||||
final bool hasMore;
|
||||
final bool loadingMore;
|
||||
final int? selectedFeedId;
|
||||
final Map<int, String?> reactions; // item id → 'up'|'down'|null
|
||||
}
|
||||
```
|
||||
|
||||
### `NewsNotifier extends AsyncNotifier<NewsState>`
|
||||
- `build()`: fetches first page (offset=0, no feed filter); initialises `reactions` from `item.reaction` on each item
|
||||
- `loadMore()`: appends next page; no-op if `loadingMore` or `!hasMore`; sets `loadingMore = true` optimistically; on error shows snackbar (error returned to caller, not thrown into AsyncError)
|
||||
- `setFeed(int? feedId)`: resets `items`, `offset`, `hasMore`, `reactions`; sets `selectedFeedId`; triggers `build()`-equivalent reload via `state = AsyncLoading()` + fetch
|
||||
- `toggleReaction(int itemId, String reaction)`: optimistic toggle in `reactions` map; calls `briefingApi.postRssReaction` or `deleteRssReaction`; reverts on error
|
||||
|
||||
### `feedsProvider extends AsyncNotifier<List<BriefingFeed>>`
|
||||
- `build()`: fetches once; cached for the session (no invalidation needed — feeds rarely change)
|
||||
|
||||
---
|
||||
|
||||
## Screen Behaviour
|
||||
|
||||
### `NewsScreen` (`ConsumerStatefulWidget`)
|
||||
|
||||
**AppBar**: title "News", subtitle "Last 90 days"
|
||||
|
||||
**Feed filter row** (below app bar, above list): `DropdownButton` with "All feeds" option + one entry per feed. On change calls `ref.read(newsProvider.notifier).setFeed(id)`.
|
||||
|
||||
**List**: `ListView.builder` of `NewsCard` widgets. Each `NewsCard` receives:
|
||||
- `item`: `RssItemMeta.fromNewsItem(item)` — a thin adapter since `NewsCard` already uses `RssItemMeta`
|
||||
- `reaction`: `newsState.reactions[item.id]`
|
||||
- `onReaction`: calls `notifier.toggleReaction`
|
||||
- `onDiscuss`: calls `_handleDiscuss(item.id)`
|
||||
|
||||
**Load more**: `ListTile` / `FilledButton.tonal` at the bottom — shows "Load more" when `hasMore && !loadingMore`, spinner when `loadingMore`, hidden when `!hasMore`.
|
||||
|
||||
**Initial loading**: `CircularProgressIndicator` centered. Error state shows message + "Retry" button that calls `ref.invalidate(newsProvider)`.
|
||||
|
||||
**Discuss flow** (`_handleDiscuss`):
|
||||
1. Track `_openingChat = {itemId}` in local `setState` (disables that card's button while in flight)
|
||||
2. Call `chatApi.openArticleInChat(itemId)`
|
||||
3. On success: `context.push(Routes.chat.replaceFirst(':id', '$conversationId'))`
|
||||
4. On error: show snackbar "Failed to open article in chat."
|
||||
5. Always: remove from `_openingChat`
|
||||
|
||||
---
|
||||
|
||||
## `RssItemMeta` Adapter
|
||||
|
||||
`NewsCard` currently consumes `RssItemMeta` (from `news_card.dart`). Add a factory on `RssItemMeta`:
|
||||
```dart
|
||||
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
source: item.source,
|
||||
snippet: item.snippet,
|
||||
publishedAt: item.publishedAt,
|
||||
);
|
||||
```
|
||||
This keeps `NewsCard` unchanged and avoids coupling the widget to a second model type.
|
||||
|
||||
---
|
||||
|
||||
## What This Does NOT Include
|
||||
|
||||
- Feed management (add/remove/refresh feeds) — that is a Settings concern
|
||||
- Offline caching
|
||||
- Pull-to-refresh (load-more button is sufficient for the initial pass)
|
||||
+276
-120
@@ -1,32 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.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';
|
||||
import 'data/repositories/write_queue.dart';
|
||||
import 'providers/api_client_provider.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'providers/capture_queue_provider.dart';
|
||||
import 'providers/capture_work_queue_provider.dart';
|
||||
import 'providers/notes_provider.dart';
|
||||
import 'providers/calendar_provider.dart';
|
||||
import 'providers/chat_provider.dart';
|
||||
import 'providers/journal_provider.dart';
|
||||
import 'providers/knowledge_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/update_provider.dart';
|
||||
import 'providers/tasks_provider.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/briefing/briefing_screen.dart';
|
||||
import 'screens/journal/journal_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 'screens/calendar/calendar_screen.dart';
|
||||
import 'providers/voice_provider.dart';
|
||||
import 'widgets/offline_banner.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
|
||||
@@ -35,6 +45,7 @@ class _RouterNotifier extends ChangeNotifier {
|
||||
_RouterNotifier(Ref ref) {
|
||||
ref.listen(authProvider, (_, _) => notifyListeners());
|
||||
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
|
||||
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +59,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
final location = state.matchedLocation;
|
||||
final serverUrl = ref.read(serverUrlProvider);
|
||||
final authStatus = ref.read(authProvider);
|
||||
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
||||
|
||||
if (serverUrl == null || serverUrl.isEmpty) {
|
||||
if (location != Routes.setup) return Routes.setup;
|
||||
@@ -61,6 +73,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Offline and never logged in on this device — can't prove identity,
|
||||
// so gate behind login. Offline + ever-logged-in falls through to
|
||||
// normal navigation with the offline banner surfacing in _Shell.
|
||||
if (authStatus == AuthStatus.offline && !hasEverLoggedIn) {
|
||||
if (location != Routes.login && location != Routes.setup) {
|
||||
return Routes.login;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
@@ -82,7 +104,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,
|
||||
@@ -116,6 +141,16 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
projectId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/projects/new',
|
||||
builder: (_, _) => const ProjectEditScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projectEdit,
|
||||
builder: (_, state) => ProjectEditScreen(
|
||||
projectId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.chat,
|
||||
builder: (_, state) => ChatScreen(
|
||||
@@ -126,17 +161,25 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state, child) => _Shell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.briefing,
|
||||
builder: (_, _) => const BriefingScreen(),
|
||||
path: Routes.journal,
|
||||
builder: (_, _) => const JournalScreen(),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.calendar,
|
||||
builder: (_, _) => const CalendarScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -151,17 +194,31 @@ class _Shell extends ConsumerStatefulWidget {
|
||||
ConsumerState<_Shell> createState() => _ShellState();
|
||||
}
|
||||
|
||||
class _ShellState extends ConsumerState<_Shell> {
|
||||
static const _tabs = [
|
||||
Routes.briefing,
|
||||
Routes.library,
|
||||
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
static const _baseTabs = [
|
||||
Routes.journal,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
Routes.projects,
|
||||
];
|
||||
|
||||
List<String> _tabs() => [
|
||||
..._baseTabs,
|
||||
Routes.calendar,
|
||||
];
|
||||
|
||||
// Minimum gap between app-resume refreshes to avoid hammering the server.
|
||||
static const _resumeCooldown = Duration(seconds: 30);
|
||||
DateTime? _lastResumeRefresh;
|
||||
int? _prevTabIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clean up any leftover APKs from previous update cycles.
|
||||
ref.read(updateProvider.notifier).cleanup();
|
||||
// Silent update check — only if we haven't already checked this session.
|
||||
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
||||
if (repoUrl != null && repoUrl.isNotEmpty) {
|
||||
@@ -170,11 +227,53 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
ref.read(updateProvider.notifier).check(repoUrl);
|
||||
}
|
||||
}
|
||||
// Sync device timezone to backend so briefing and chat use local time.
|
||||
// Sync device timezone to backend so journal and chat use local time.
|
||||
_syncTimezone();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) return;
|
||||
final now = DateTime.now();
|
||||
if (_lastResumeRefresh != null &&
|
||||
now.difference(_lastResumeRefresh!) < _resumeCooldown) {
|
||||
return;
|
||||
}
|
||||
_lastResumeRefresh = now;
|
||||
_refreshAll();
|
||||
}
|
||||
|
||||
/// Refresh every major data provider. Safe to call speculatively —
|
||||
/// providers that aren't currently watched are already disposed.
|
||||
void _refreshAll() {
|
||||
ref.read(conversationsProvider.notifier).refresh();
|
||||
ref.read(calendarProvider.notifier).refresh();
|
||||
ref.read(knowledgeProvider.notifier).refresh();
|
||||
// journalProvider is an AsyncNotifier; invalidating is safe even if
|
||||
// the journal screen isn't currently mounted.
|
||||
ref.invalidate(journalProvider);
|
||||
}
|
||||
|
||||
/// Refresh only the provider backing the given shell tab route.
|
||||
void _refreshTab(String route) {
|
||||
if (route == Routes.journal) {
|
||||
ref.invalidate(journalProvider);
|
||||
} else if (route == Routes.knowledge) {
|
||||
ref.read(knowledgeProvider.notifier).refresh();
|
||||
} else if (route == Routes.conversations) {
|
||||
ref.read(conversationsProvider.notifier).refresh();
|
||||
} else if (route == Routes.calendar) {
|
||||
ref.read(calendarProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTimezone() async {
|
||||
try {
|
||||
final tzInfo = await FlutterTimezone.getLocalTimezone();
|
||||
@@ -184,73 +283,62 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
}
|
||||
}
|
||||
|
||||
int _tabIndex(String location) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
int _tabIndex(String location, List<String> tabs) {
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
if (location.startsWith(tabs[i])) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _showUpdateDialog(UpdateState update) {
|
||||
showDialog<void>(
|
||||
void _showMoreSheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final state = ref.watch(updateProvider);
|
||||
final isDownloading = state.status == UpdateStatus.downloading;
|
||||
return AlertDialog(
|
||||
title: const Text('Update available'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
|
||||
if (state.currentVersion != null)
|
||||
Text(
|
||||
'Installed: v${state.currentVersion}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (isDownloading) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
value: state.downloadProgress > 0
|
||||
? state.downloadProgress
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Downloading… '
|
||||
'${(state.downloadProgress * 100).toStringAsFixed(0)}%',
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folder),
|
||||
title: const Text('Projects'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.projects);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
if (!isDownloading && state.downloadUrl != null)
|
||||
FilledButton(
|
||||
onPressed: () => ref
|
||||
.read(updateProvider.notifier)
|
||||
.downloadAndInstall(),
|
||||
child: const Text('Download & Install'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.calendar),
|
||||
title: const Text('Calendar'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.calendar);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showUpdateSnackbar(UpdateState update) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('v${update.latestVersion} ready to install'),
|
||||
duration: const Duration(seconds: 6),
|
||||
action: SnackBarAction(
|
||||
label: 'Install',
|
||||
onPressed: () => ref.read(updateProvider.notifier).install(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showQueueFailureSnackbar(QueueFailure failure) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(failure.message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 6),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -259,14 +347,40 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
Widget build(BuildContext context) {
|
||||
// Show update dialog once when a new version is detected.
|
||||
ref.listen(updateProvider, (prev, next) {
|
||||
if (next.status == UpdateStatus.available &&
|
||||
prev?.status != UpdateStatus.available) {
|
||||
if (next.status == UpdateStatus.readyToInstall &&
|
||||
prev?.status != UpdateStatus.readyToInstall) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _showUpdateDialog(next));
|
||||
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 3 — drain the offline write queue when we transition into
|
||||
// an online state. Fires for unknown→authenticated (cold start),
|
||||
// offline→authenticated (came back), and unauthenticated→authenticated
|
||||
// (login). Idempotent if the queue is empty.
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
if (next == AuthStatus.authenticated &&
|
||||
prev != AuthStatus.authenticated) {
|
||||
ref.read(writeQueueProvider).replay();
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 3 — surface queue failures (overwrites, missing targets, 4xx
|
||||
// rejections) so the user knows their offline edit didn't land.
|
||||
ref.listen(writeQueueFailuresProvider, (_, next) {
|
||||
next.whenData(_showQueueFailureSnackbar);
|
||||
});
|
||||
final tabs = _tabs();
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final index = _tabIndex(location);
|
||||
final index = _tabIndex(location, tabs);
|
||||
|
||||
// Refresh the incoming tab's data when switching between shell tabs.
|
||||
if (_prevTabIndex != null && _prevTabIndex != index) {
|
||||
final route = index < tabs.length ? tabs[index] : tabs[0];
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
|
||||
}
|
||||
_prevTabIndex = index;
|
||||
|
||||
final child = widget.child;
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
|
||||
@@ -275,30 +389,41 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
const _QuickCaptureBar(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||
onDestinationSelected: (i) => context.go(tabs[i]),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: Text('Briefing'),
|
||||
icon: Icon(LucideIcons.bookOpen),
|
||||
selectedIcon: Icon(LucideIcons.bookOpen),
|
||||
label: Text('Journal'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.library_books_outlined),
|
||||
selectedIcon: Icon(Icons.library_books),
|
||||
label: Text('Library'),
|
||||
icon: Icon(LucideIcons.lightbulb),
|
||||
selectedIcon: Icon(LucideIcons.lightbulb),
|
||||
label: Text('Knowledge'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
icon: Icon(LucideIcons.messageCircle),
|
||||
selectedIcon: Icon(LucideIcons.messageCircle),
|
||||
label: Text('Chat'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(LucideIcons.folder),
|
||||
selectedIcon: Icon(LucideIcons.folder),
|
||||
label: Text('Projects'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(LucideIcons.calendar),
|
||||
selectedIcon: Icon(LucideIcons.calendar),
|
||||
label: Text('Calendar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
@@ -315,6 +440,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
const _QuickCaptureBar(),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
@@ -326,24 +452,35 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||
selectedIndex: index >= 3 ? 3 : index,
|
||||
onDestinationSelected: (i) {
|
||||
if (i == 3) {
|
||||
_showMoreSheet(context);
|
||||
} else {
|
||||
context.go(tabs[i]);
|
||||
}
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: 'Briefing',
|
||||
icon: Icon(LucideIcons.bookOpen),
|
||||
selectedIcon: Icon(LucideIcons.bookOpen),
|
||||
label: 'Journal',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.library_books_outlined),
|
||||
selectedIcon: Icon(Icons.library_books),
|
||||
label: 'Library',
|
||||
icon: Icon(LucideIcons.lightbulb),
|
||||
selectedIcon: Icon(LucideIcons.lightbulb),
|
||||
label: 'Knowledge',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
icon: Icon(LucideIcons.messageCircle),
|
||||
selectedIcon: Icon(LucideIcons.messageCircle),
|
||||
label: 'Chat',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(LucideIcons.moreHorizontal),
|
||||
selectedIcon: Icon(LucideIcons.moreHorizontal),
|
||||
label: 'More',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -384,35 +521,46 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
if (!mounted) return;
|
||||
final queue = ref.read(captureQueueProvider);
|
||||
if (queue.isEmpty) return;
|
||||
final api = ref.read(quickCaptureApiProvider);
|
||||
for (final text in List<String>.from(queue)) {
|
||||
if (!mounted) break;
|
||||
try {
|
||||
final result = await api.capture(text);
|
||||
// Dequeue before the mounted check — SharedPreferences doesn't need
|
||||
// the widget alive, and skipping this would leave a ghost item.
|
||||
final conv =
|
||||
await ref.read(conversationsProvider.notifier).create('');
|
||||
final chatRepo = ref.read(chatRepositoryProvider);
|
||||
await chatRepo.sendMessage(conv.id, text);
|
||||
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
if (!mounted) break;
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
}
|
||||
} on NetworkException {
|
||||
break;
|
||||
} catch (_) {
|
||||
// Server error or unexpected failure — drop from queue to prevent
|
||||
// ghost items that can never be cleared.
|
||||
// Server error — drop from queue to prevent ghost items.
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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…';
|
||||
}
|
||||
@@ -452,14 +600,16 @@ 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),
|
||||
prefixIcon: totalPending > 0
|
||||
? Badge(
|
||||
label: Text('$totalPending'),
|
||||
child: const Icon(Icons.cloud_upload_outlined),
|
||||
child: const Icon(LucideIcons.uploadCloud),
|
||||
)
|
||||
: isWorking
|
||||
? const Padding(
|
||||
@@ -471,10 +621,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
: const Icon(LucideIcons.sparkles),
|
||||
suffixIcon: _controller.text.trim().isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
icon: const Icon(LucideIcons.send),
|
||||
onPressed: _submit,
|
||||
tooltip: 'Capture',
|
||||
)
|
||||
@@ -482,8 +632,14 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
VoiceMicButton(
|
||||
mode: ref.watch(voiceProvider).mode,
|
||||
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
|
||||
amplitude: ref.watch(voiceProvider).amplitude,
|
||||
onTap: _toggleCaptureMic,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
icon: const Icon(LucideIcons.settings),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push(Routes.settings),
|
||||
),
|
||||
|
||||
@@ -9,12 +9,14 @@ 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 journal = '/journal';
|
||||
static const calendar = '/calendar';
|
||||
static const projectTasks = '/projects/:id/tasks';
|
||||
}
|
||||
|
||||
@@ -17,3 +17,12 @@ class AuthException extends AppException {
|
||||
class NotFoundException extends AppException {
|
||||
const NotFoundException(super.message);
|
||||
}
|
||||
|
||||
/// 4xx/5xx response received from the server (excluding 401/404 which have
|
||||
/// dedicated subclasses). Distinguished from `NetworkException` (connection
|
||||
/// failure) so the offline write queue can drop non-retryable failures
|
||||
/// instead of looping forever on a 422.
|
||||
class ServerException extends AppException {
|
||||
final int statusCode;
|
||||
const ServerException(super.message, this.statusCode);
|
||||
}
|
||||
|
||||
+149
-35
@@ -2,35 +2,142 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors the web frontend's design system (`docs/design-system.md` in
|
||||
// fabledscribe). Foundation pass shipped on web 2026-04-27 in `7a9a8b7`;
|
||||
// this is the equivalent palette swap for Flutter. Per-screen "surface
|
||||
// phase" reclassification (button Hybrid rule, border audit, etc.) is
|
||||
// deferred — most widgets read `colorScheme.primary` so the palette flip
|
||||
// alone covers a large surface.
|
||||
|
||||
const _darkBackground = Color(0xFF111113);
|
||||
const _darkSurface = Color(0xFF18181C);
|
||||
const _darkSurfaceVar = Color(0xFF1E1E24);
|
||||
const _darkPrimary = Color(0xFF6366F1);
|
||||
const _darkOnSurface = Color(0xFFE8E8F0);
|
||||
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
||||
const _darkOutline = Color(0xFF2E2E3A);
|
||||
// Dark mode — Obsidian / Iron / Pewter / Parchment (FabledSword baseline)
|
||||
const _darkBackground = Color(0xFF14171A); // Obsidian
|
||||
const _darkSurface = Color(0xFF1E2228); // Iron
|
||||
const _darkSurfaceVar = Color(0xFF2C313A); // Slate
|
||||
const _darkPrimary = Color(0xFF5B4A8A); // dusty violet (Scribe accent)
|
||||
const _darkPrimaryDeep = Color(0xFF3F3560); // gradient stop
|
||||
const _darkOnSurface = Color(0xFFE8E4D8); // Parchment
|
||||
const _darkOnSurfaceVar = Color(0xFFC2BFB4); // Vellum
|
||||
const _darkOutline = Color(0xFF3F4651); // Pewter
|
||||
|
||||
const _lightBackground = Color(0xFFF4F4F8);
|
||||
const _lightSurface = Color(0xFFFFFFFF);
|
||||
const _lightSurfaceVar = Color(0xFFF0F0F5);
|
||||
const _lightPrimary = Color(0xFF4F46E5);
|
||||
const _lightOnSurface = Color(0xFF18181C);
|
||||
const _lightOnSurfaceVar = Color(0xFF6B6B88);
|
||||
const _lightOutline = Color(0xFFD4D4E4);
|
||||
// Light mode — warm parchment (Scribe iteration)
|
||||
const _lightBackground = Color(0xFFF5F1E8); // warm cream page
|
||||
const _lightSurface = Color(0xFFFBF8F0); // near-white card
|
||||
const _lightSurfaceVar = Color(0xFFEFEAE0); // inset / hover surface
|
||||
const _lightPrimary = Color(0xFF5B4A8A); // dusty violet (same on both modes)
|
||||
const _lightOnSurface = Color(0xFF14171A); // deep ink (Obsidian inverted)
|
||||
const _lightOnSurfaceVar = Color(0xFF5A5852); // warm mid grey
|
||||
const _lightOutline = Color(0xFFD9D6CE); // warm light pewter
|
||||
|
||||
// Semantic — identical across themes
|
||||
const _semanticError = Color(0xFFC04A1F); // terracotta — validation/error
|
||||
const _semanticErrorBg = Color(0xFF7E2A1F); // Oxblood-hover for dark error container
|
||||
const _semanticErrorFg = Color(0xFFFEE2E2); // light text on dark error container
|
||||
|
||||
// Action tokens — Hybrid rule per the doc. These don't fit ColorScheme
|
||||
// natively (Material's primary/secondary/tertiary slots all carry the
|
||||
// brand accent), so they live on a ThemeExtension exposed below.
|
||||
const _actionPrimary = Color(0xFF4A5D3F); // Moss
|
||||
const _actionPrimaryHover = Color(0xFF5A6F4D);
|
||||
const _actionSecondary = Color(0xFF8B7355); // Bronze
|
||||
const _actionSecondaryHover = Color(0xFFA0876A);
|
||||
const _actionDestructive = Color(0xFF6B2118); // Oxblood
|
||||
const _actionDestructiveHover = Color(0xFF7E2A1F);
|
||||
const _actionGhostBorder = Color(0xFF3F4651); // Pewter (same as outline)
|
||||
|
||||
// ── Action ThemeExtension ─────────────────────────────────────────────────────
|
||||
// Read with: Theme.of(context).extension<ActionColors>()!.primary
|
||||
//
|
||||
// Flutter's ColorScheme has primary/secondary/tertiary all conceptually
|
||||
// "branded", whereas the doc's Hybrid rule reserves accent for brand
|
||||
// moments (Send, empty-state CTAs) and routes action buttons through a
|
||||
// separate Moss/Bronze/Oxblood/Pewter palette. This extension carries
|
||||
// those tokens without polluting ColorScheme.
|
||||
|
||||
@immutable
|
||||
class ActionColors extends ThemeExtension<ActionColors> {
|
||||
final Color primary; // Moss — Save / Confirm
|
||||
final Color primaryHover;
|
||||
final Color secondary; // Bronze — Cancel / alternate paths
|
||||
final Color secondaryHover;
|
||||
final Color destructive; // Oxblood — Delete / irreversible
|
||||
final Color destructiveHover;
|
||||
final Color ghostBorder; // Pewter — tertiary / "later" / "skip"
|
||||
|
||||
const ActionColors({
|
||||
required this.primary,
|
||||
required this.primaryHover,
|
||||
required this.secondary,
|
||||
required this.secondaryHover,
|
||||
required this.destructive,
|
||||
required this.destructiveHover,
|
||||
required this.ghostBorder,
|
||||
});
|
||||
|
||||
static const _kStandard = ActionColors(
|
||||
primary: _actionPrimary,
|
||||
primaryHover: _actionPrimaryHover,
|
||||
secondary: _actionSecondary,
|
||||
secondaryHover: _actionSecondaryHover,
|
||||
destructive: _actionDestructive,
|
||||
destructiveHover: _actionDestructiveHover,
|
||||
ghostBorder: _actionGhostBorder,
|
||||
);
|
||||
|
||||
@override
|
||||
ActionColors copyWith({
|
||||
Color? primary,
|
||||
Color? primaryHover,
|
||||
Color? secondary,
|
||||
Color? secondaryHover,
|
||||
Color? destructive,
|
||||
Color? destructiveHover,
|
||||
Color? ghostBorder,
|
||||
}) {
|
||||
return ActionColors(
|
||||
primary: primary ?? this.primary,
|
||||
primaryHover: primaryHover ?? this.primaryHover,
|
||||
secondary: secondary ?? this.secondary,
|
||||
secondaryHover: secondaryHover ?? this.secondaryHover,
|
||||
destructive: destructive ?? this.destructive,
|
||||
destructiveHover: destructiveHover ?? this.destructiveHover,
|
||||
ghostBorder: ghostBorder ?? this.ghostBorder,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ActionColors lerp(ThemeExtension<ActionColors>? other, double t) {
|
||||
if (other is! ActionColors) return this;
|
||||
return ActionColors(
|
||||
primary: Color.lerp(primary, other.primary, t)!,
|
||||
primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!,
|
||||
secondary: Color.lerp(secondary, other.secondary, t)!,
|
||||
secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!,
|
||||
destructive: Color.lerp(destructive, other.destructive, t)!,
|
||||
destructiveHover: Color.lerp(destructiveHover, other.destructiveHover, t)!,
|
||||
ghostBorder: Color.lerp(ghostBorder, other.ghostBorder, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||
// Inter for body / labels / titleMedium-and-below.
|
||||
// Fraunces for display / headline / titleLarge — only at ≥18px per the doc.
|
||||
// JetBrains Mono available via GoogleFonts.jetBrainsMono() at call sites for
|
||||
// code blocks (Flutter's TextTheme has no dedicated mono slot).
|
||||
|
||||
TextTheme _buildTextTheme(TextTheme base) {
|
||||
final inter = GoogleFonts.interTextTheme(base);
|
||||
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||
return base.copyWith(
|
||||
// Headings / titles use Fraunces
|
||||
return inter.copyWith(
|
||||
displayLarge: fraunces.displayLarge,
|
||||
displayMedium: fraunces.displayMedium,
|
||||
displaySmall: fraunces.displaySmall,
|
||||
headlineLarge: fraunces.headlineLarge,
|
||||
headlineMedium: fraunces.headlineMedium,
|
||||
headlineSmall: fraunces.headlineSmall,
|
||||
titleLarge: fraunces.titleLarge,
|
||||
titleMedium: fraunces.titleMedium,
|
||||
// Body / labels remain system default
|
||||
// titleMedium / titleSmall / body* / label* stay Inter
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +148,7 @@ ThemeData fabledDarkTheme() {
|
||||
brightness: Brightness.dark,
|
||||
primary: _darkPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFF3730A3),
|
||||
primaryContainer: _darkPrimaryDeep,
|
||||
onPrimaryContainer: _darkOnSurface,
|
||||
secondary: _darkPrimary,
|
||||
onSecondary: Colors.white,
|
||||
@@ -51,10 +158,10 @@ ThemeData fabledDarkTheme() {
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _darkSurfaceVar,
|
||||
onTertiaryContainer: _darkOnSurface,
|
||||
error: const Color(0xFFEF4444),
|
||||
error: _semanticError,
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFF7F1D1D),
|
||||
onErrorContainer: const Color(0xFFFEE2E2),
|
||||
errorContainer: _semanticErrorBg,
|
||||
onErrorContainer: _semanticErrorFg,
|
||||
surface: _darkSurface,
|
||||
onSurface: _darkOnSurface,
|
||||
surfaceContainerHighest: _darkSurfaceVar,
|
||||
@@ -73,6 +180,7 @@ ThemeData fabledDarkTheme() {
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _darkBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||
extensions: const [ActionColors._kStandard],
|
||||
cardTheme: CardThemeData(
|
||||
color: _darkSurface,
|
||||
elevation: 2,
|
||||
@@ -91,15 +199,15 @@ ThemeData fabledDarkTheme() {
|
||||
filled: true,
|
||||
fillColor: _darkSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
@@ -118,7 +226,9 @@ ThemeData fabledLightTheme() {
|
||||
brightness: Brightness.light,
|
||||
primary: _lightPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFFE0E0FF),
|
||||
// Warm parchment-tinted primary container, replacing the prior cool
|
||||
// indigo `#EDE5FF`. Used for chip/badge backgrounds at low alpha.
|
||||
primaryContainer: const Color(0xFFEDE9F4),
|
||||
onPrimaryContainer: _lightOnSurface,
|
||||
secondary: _lightPrimary,
|
||||
onSecondary: Colors.white,
|
||||
@@ -128,10 +238,10 @@ ThemeData fabledLightTheme() {
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _lightSurfaceVar,
|
||||
onTertiaryContainer: _lightOnSurface,
|
||||
error: const Color(0xFFDC2626),
|
||||
error: _semanticError,
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFFFEE2E2),
|
||||
onErrorContainer: const Color(0xFF7F1D1D),
|
||||
onErrorContainer: _semanticErrorBg,
|
||||
surface: _lightSurface,
|
||||
onSurface: _lightOnSurface,
|
||||
surfaceContainerHighest: _lightSurfaceVar,
|
||||
@@ -150,6 +260,7 @@ ThemeData fabledLightTheme() {
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _lightBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||
extensions: const [ActionColors._kStandard],
|
||||
cardTheme: CardThemeData(
|
||||
color: _lightSurface,
|
||||
elevation: 1,
|
||||
@@ -168,15 +279,15 @@ ThemeData fabledLightTheme() {
|
||||
filled: true,
|
||||
fillColor: _lightSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
@@ -191,7 +302,10 @@ ThemeData fabledLightTheme() {
|
||||
}
|
||||
|
||||
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
||||
// Brand-moment CTA equivalent of the web's `--gradient-cta` — chat send,
|
||||
// journal send, primary "Scribe-feature" actions. Reserve for those moments
|
||||
// per the doc's Hybrid rule; use FilledButton with ActionColors.primary
|
||||
// (Moss) for everything else.
|
||||
|
||||
class GradientButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
@@ -218,15 +332,15 @@ class GradientButton extends StatelessWidget {
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||
colors: [_darkPrimary, _darkPrimaryDeep],
|
||||
),
|
||||
color: disabled ? const Color(0xFF6366F1) : null,
|
||||
color: disabled ? _darkPrimary : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: disabled
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
|
||||
color: _darkPrimary.withValues(alpha: 0.45),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
|
||||
@@ -63,5 +63,21 @@ AppException dioToApp(DioException e) {
|
||||
final status = e.response?.statusCode;
|
||||
if (status == 401) return const AuthException('Not authenticated.');
|
||||
if (status == 404) return const NotFoundException('Resource not found.');
|
||||
if (status != null && status >= 400) {
|
||||
final msg = _extractServerErrorMessage(e.response) ??
|
||||
'Request failed (HTTP $status).';
|
||||
return ServerException(msg, status);
|
||||
}
|
||||
return NetworkException(e.message ?? 'Unknown network error.');
|
||||
}
|
||||
|
||||
String? _extractServerErrorMessage(Response? resp) {
|
||||
final data = resp?.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
for (final key in const ['detail', 'error', 'message']) {
|
||||
final value = data[key];
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/briefing_conversation.dart';
|
||||
import '../models/message.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class BriefingApi {
|
||||
final Dio _dio;
|
||||
const BriefingApi(this._dio);
|
||||
|
||||
/// GET /api/briefing/conversations/today
|
||||
/// Returns (or creates) today's briefing conversation with messages embedded.
|
||||
Future<BriefingConversation> getToday() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/conversations/today');
|
||||
return BriefingConversation.fromJson(
|
||||
response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/conversations
|
||||
/// Returns list of past briefing conversations (no messages embedded).
|
||||
Future<List<BriefingConversation>> getHistory() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/conversations');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['conversations'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => BriefingConversation.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/conversations/`<id>`/messages
|
||||
Future<List<Message>> getMessages(int convId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.get('/api/briefing/conversations/$convId/messages');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['messages'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/briefing/trigger body: {"slot": slot}
|
||||
/// slot: "compilation" | "morning" | "midday" | "afternoon"
|
||||
Future<void> triggerSlot(String slot) async {
|
||||
try {
|
||||
await _dio.post('/api/briefing/trigger', data: {'slot': slot});
|
||||
} on DioException catch (e) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,27 @@ import '../models/conversation.dart';
|
||||
import '../models/message.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
sealed class ChatStreamEvent {}
|
||||
|
||||
class ChatTextChunk extends ChatStreamEvent {
|
||||
final String text;
|
||||
ChatTextChunk(this.text);
|
||||
}
|
||||
|
||||
class ChatStatusUpdate extends ChatStreamEvent {
|
||||
final String status; // empty string = clear status
|
||||
ChatStatusUpdate(this.status);
|
||||
}
|
||||
|
||||
/// A single tool call fired during generation. Mirrors the `tool_call` SSE
|
||||
/// event emitted by `generation_task.py` and the `tool_calls` array persisted
|
||||
/// on the assistant Message row — same shape either way so the UI can render
|
||||
/// live chips during streaming and re-render them from storage after reload.
|
||||
class ChatToolCall extends ChatStreamEvent {
|
||||
final Map<String, dynamic> toolCall;
|
||||
ChatToolCall(this.toolCall);
|
||||
}
|
||||
|
||||
class ChatApi {
|
||||
final Dio _dio;
|
||||
const ChatApi(this._dio);
|
||||
@@ -71,8 +92,8 @@ class ChatApi {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: GET the SSE stream and yield text chunks.
|
||||
Stream<String> streamGeneration(int conversationId) async* {
|
||||
// Step 2: GET the SSE stream and yield typed events (text chunks + status updates).
|
||||
Stream<ChatStreamEvent> streamGeneration(int conversationId) async* {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/chat/conversations/$conversationId/generation/stream',
|
||||
@@ -108,14 +129,29 @@ class ChatApi {
|
||||
if (data == '[DONE]') return;
|
||||
if (currentEvent == 'done' || currentEvent == 'error') return;
|
||||
|
||||
// Parse as JSON if possible, otherwise yield raw text.
|
||||
if (currentEvent == 'chunk' || currentEvent.isEmpty) {
|
||||
try {
|
||||
final obj = json.decode(data) as Map<String, dynamic>;
|
||||
final text = obj['text'] as String? ?? '';
|
||||
if (text.isNotEmpty) yield text;
|
||||
if (text.isNotEmpty) yield ChatTextChunk(text);
|
||||
} catch (_) {
|
||||
if (data.isNotEmpty) yield data;
|
||||
if (data.isNotEmpty) yield ChatTextChunk(data);
|
||||
}
|
||||
} else if (currentEvent == 'status') {
|
||||
try {
|
||||
final obj = json.decode(data) as Map<String, dynamic>;
|
||||
final status = obj['status'] as String? ?? '';
|
||||
yield ChatStatusUpdate(status);
|
||||
} catch (_) {
|
||||
// Ignore malformed status events
|
||||
}
|
||||
} else if (currentEvent == 'tool_call') {
|
||||
try {
|
||||
final obj = json.decode(data) as Map<String, dynamic>;
|
||||
final tc = obj['tool_call'];
|
||||
if (tc is Map<String, dynamic>) yield ChatToolCall(tc);
|
||||
} catch (_) {
|
||||
// Ignore malformed tool_call events
|
||||
}
|
||||
}
|
||||
} else if (line.isEmpty) {
|
||||
@@ -127,4 +163,5 @@ class ChatApi {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/calendar_event.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class EventsApi {
|
||||
final Dio _dio;
|
||||
const EventsApi(this._dio);
|
||||
|
||||
/// GET /api/events?from={iso}&to={iso}
|
||||
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/events',
|
||||
queryParameters: {
|
||||
'from': from.toUtc().toIso8601String(),
|
||||
'to': to.toUtc().toIso8601String(),
|
||||
},
|
||||
);
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/events
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/events', data: payload);
|
||||
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/events/{id}
|
||||
Future<CalendarEvent> updateEvent(
|
||||
int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final response = await _dio.patch('/api/events/$id', data: fields);
|
||||
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/events/{id}
|
||||
Future<void> deleteEvent(int id) async {
|
||||
try {
|
||||
await _dio.delete('/api/events/$id');
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/journal_day.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class JournalApi {
|
||||
final Dio _dio;
|
||||
const JournalApi(this._dio);
|
||||
|
||||
/// GET /api/journal/today
|
||||
/// Creates today's journal conversation + daily prep on demand if absent,
|
||||
/// then returns the day payload.
|
||||
Future<JournalDay> getToday() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/journal/today');
|
||||
return JournalDay.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/journal/day/<iso_date>
|
||||
Future<JournalDay> getDay(String isoDate) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/journal/day/$isoDate');
|
||||
return JournalDay.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/journal/days — list of dates with journal content, newest first.
|
||||
Future<List<String>> getDays() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/journal/days');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['days'] as List<dynamic>;
|
||||
return list.map((e) => e as String).toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/journal/trigger-prep — force-regenerate today's daily prep
|
||||
/// (or a specific day if [isoDate] is given).
|
||||
Future<void> triggerPrep({String? isoDate}) async {
|
||||
try {
|
||||
final body = <String, dynamic>{};
|
||||
if (isoDate != null) body['date'] = isoDate;
|
||||
await _dio.post('/api/journal/trigger-prep', data: body);
|
||||
} 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,
|
||||
|
||||
@@ -10,4 +10,13 @@ class SettingsApi {
|
||||
data: {'user_timezone': ianaTimezone},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getAll() async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('/api/settings');
|
||||
return response.data ?? {};
|
||||
}
|
||||
|
||||
Future<void> update(Map<String, String> updates) async {
|
||||
await _dio.put<void>('/api/settings', data: updates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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 when voice is enabled and at least STT is ready.
|
||||
/// TTS is optional — voice mode works without it (STT-only).
|
||||
bool get fullyAvailable => enabled && stt;
|
||||
|
||||
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 audio bytes (WAV) and return the transcript string.
|
||||
/// [context] is optional recent conversation text passed as initial_prompt
|
||||
/// to Whisper, reducing mishearings of domain-specific words.
|
||||
/// Returns empty string on empty or error response.
|
||||
Future<String> transcribe(Uint8List audioBytes, {String? context}) async {
|
||||
try {
|
||||
final fields = <String, dynamic>{
|
||||
'audio': MultipartFile.fromBytes(
|
||||
audioBytes,
|
||||
filename: 'audio.wav',
|
||||
contentType: DioMediaType('audio', 'wav'),
|
||||
),
|
||||
if (context != null && context.isNotEmpty) 'context': context,
|
||||
};
|
||||
final formData = FormData.fromMap(fields);
|
||||
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,760 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
|
||||
|
||||
import '../models/calendar_event.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../models/milestone.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/project.dart';
|
||||
import '../models/task.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
// ── Tables ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each cached_* table mirrors the corresponding model 1:1. Tags / list fields
|
||||
// are stored as JSON-encoded text; SQLite lacks a native list type and
|
||||
// Drift's typed converters add ceremony we don't need for read-side caching.
|
||||
// `cachedAt` tracks when the row was last written from a successful API
|
||||
// response; the SyncMetadata table tracks the per-domain "last bulk fetch"
|
||||
// timestamp used by the OfflineBanner.
|
||||
|
||||
class CachedNotes extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get body => text()();
|
||||
TextColumn get tagsJson => text().withDefault(const Constant('[]'))();
|
||||
TextColumn get noteType => text().withDefault(const Constant('note'))();
|
||||
IntColumn get projectId => integer().nullable()();
|
||||
IntColumn get milestoneId => integer().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedTasks extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
TextColumn get status => text()();
|
||||
TextColumn get priority => text()();
|
||||
DateTimeColumn get dueDate => dateTime().nullable()();
|
||||
IntColumn get projectId => integer().nullable()();
|
||||
IntColumn get milestoneId => integer().nullable()();
|
||||
IntColumn get parentId => integer().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedProjects extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
TextColumn get goal => text().nullable()();
|
||||
TextColumn get status => text()();
|
||||
TextColumn get color => text().nullable()();
|
||||
TextColumn get autoSummary => text().nullable()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedMilestones extends Table {
|
||||
IntColumn get id => integer()();
|
||||
IntColumn get projectId => integer()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
TextColumn get status => text()();
|
||||
IntColumn get orderIndex => integer().withDefault(const Constant(0))();
|
||||
IntColumn get total => integer().withDefault(const Constant(0))();
|
||||
IntColumn get completed => integer().withDefault(const Constant(0))();
|
||||
RealColumn get pct => real().withDefault(const Constant(0.0))();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedCalendarEvents extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
DateTimeColumn get startDt => dateTime()();
|
||||
DateTimeColumn get endDt => dateTime().nullable()();
|
||||
BoolColumn get allDay => boolean().withDefault(const Constant(false))();
|
||||
TextColumn get description => text().withDefault(const Constant(''))();
|
||||
TextColumn get location => text().withDefault(const Constant(''))();
|
||||
TextColumn get color => text().withDefault(const Constant(''))();
|
||||
TextColumn get recurrence => text().nullable()();
|
||||
IntColumn get projectId => integer().nullable()();
|
||||
IntColumn get reminderMinutes => integer().nullable()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class CachedConversations extends Table {
|
||||
IntColumn get id => integer()();
|
||||
TextColumn get title => text()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get cachedAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class SyncMetadata extends Table {
|
||||
TextColumn get domain => text()();
|
||||
DateTimeColumn get lastSyncedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {domain};
|
||||
}
|
||||
|
||||
/// Phase 3 — generic offline-write queue. One row per pending API call;
|
||||
/// drained in oldest-first order when the device comes back online.
|
||||
///
|
||||
/// `targetId` is the server id for updates/deletes; `tempId` is the
|
||||
/// client-allocated negative placeholder for offline creates. `payloadJson`
|
||||
/// is the typed args the API method needs (verb-specific shape).
|
||||
/// `baselineUpdatedAt` snapshots the cached row's `updated_at` at edit
|
||||
/// time so the replayer can drop the op if the server has moved on
|
||||
/// (server-wins conflict resolution).
|
||||
class PendingWrites extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get domain => text()();
|
||||
TextColumn get verb => text()();
|
||||
IntColumn get targetId => integer().nullable()();
|
||||
IntColumn get tempId => integer().nullable()();
|
||||
TextColumn get payloadJson => text().withDefault(const Constant('{}'))();
|
||||
DateTimeColumn get baselineUpdatedAt => dateTime().nullable()();
|
||||
DateTimeColumn get createdAt =>
|
||||
dateTime().clientDefault(() => DateTime.now())();
|
||||
IntColumn get tries => integer().withDefault(const Constant(0))();
|
||||
TextColumn get lastError => text().nullable()();
|
||||
}
|
||||
|
||||
const String kSyncDomainNotes = 'notes';
|
||||
const String kSyncDomainTasks = 'tasks';
|
||||
const String kSyncDomainProjects = 'projects';
|
||||
const String kSyncDomainMilestones = 'milestones';
|
||||
const String kSyncDomainEvents = 'events';
|
||||
const String kSyncDomainConversations = 'conversations';
|
||||
|
||||
const String kWriteVerbCreate = 'create';
|
||||
const String kWriteVerbUpdate = 'update';
|
||||
const String kWriteVerbDelete = 'delete';
|
||||
|
||||
@DriftDatabase(tables: [
|
||||
CachedNotes,
|
||||
CachedTasks,
|
||||
CachedProjects,
|
||||
CachedMilestones,
|
||||
CachedCalendarEvents,
|
||||
CachedConversations,
|
||||
SyncMetadata,
|
||||
PendingWrites,
|
||||
])
|
||||
class FabledDatabase extends _$FabledDatabase {
|
||||
FabledDatabase() : super(_openConnection());
|
||||
FabledDatabase.forTesting(super.executor);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (m) => m.createAll(),
|
||||
onUpgrade: (m, from, to) async {
|
||||
// v1 → v2: added the per-domain caches beyond notes.
|
||||
if (from < 2) {
|
||||
await m.createTable(cachedTasks);
|
||||
await m.createTable(cachedProjects);
|
||||
await m.createTable(cachedMilestones);
|
||||
await m.createTable(cachedCalendarEvents);
|
||||
await m.createTable(cachedConversations);
|
||||
}
|
||||
// v2 → v3: added the offline write queue.
|
||||
if (from < 3) {
|
||||
await m.createTable(pendingWrites);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Notes ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Note>> getAllNotes() async {
|
||||
final rows = await (select(cachedNotes)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_noteFromRow).toList();
|
||||
}
|
||||
|
||||
Future<Note?> getNote(int id) async {
|
||||
final row = await (select(cachedNotes)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _noteFromRow(row);
|
||||
}
|
||||
|
||||
Future<void> replaceAllNotes(List<Note> notes) async {
|
||||
await transaction(() async {
|
||||
await delete(cachedNotes).go();
|
||||
if (notes.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(cachedNotes, notes.map(_noteToCompanion).toList());
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainNotes, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertNote(Note note) async {
|
||||
await into(cachedNotes).insertOnConflictUpdate(_noteToCompanion(note));
|
||||
}
|
||||
|
||||
Future<void> deleteNote(int id) async {
|
||||
await (delete(cachedNotes)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Task>> getAllTasks() async {
|
||||
final rows = await (select(cachedTasks)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_taskFromRow).toList();
|
||||
}
|
||||
|
||||
Future<Task?> getTask(int id) async {
|
||||
final row = await (select(cachedTasks)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _taskFromRow(row);
|
||||
}
|
||||
|
||||
Future<List<Task>> getTasksByProject(int projectId) async {
|
||||
final rows = await (select(cachedTasks)
|
||||
..where((t) => t.projectId.equals(projectId))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_taskFromRow).toList();
|
||||
}
|
||||
|
||||
Future<List<Task>> getSubTasks(int parentId) async {
|
||||
final rows = await (select(cachedTasks)
|
||||
..where((t) => t.parentId.equals(parentId))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_taskFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceAllTasks(List<Task> tasks) async {
|
||||
await transaction(() async {
|
||||
await delete(cachedTasks).go();
|
||||
if (tasks.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(cachedTasks, tasks.map(_taskToCompanion).toList());
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainTasks, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertTask(Task task) async {
|
||||
await into(cachedTasks).insertOnConflictUpdate(_taskToCompanion(task));
|
||||
}
|
||||
|
||||
Future<void> deleteTask(int id) async {
|
||||
await (delete(cachedTasks)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Projects ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Project>> getAllProjects() async {
|
||||
final rows = await (select(cachedProjects)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_projectFromRow).toList();
|
||||
}
|
||||
|
||||
Future<Project?> getProject(int id) async {
|
||||
final row = await (select(cachedProjects)..where((t) => t.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _projectFromRow(row);
|
||||
}
|
||||
|
||||
Future<void> replaceAllProjects(List<Project> projects) async {
|
||||
await transaction(() async {
|
||||
await delete(cachedProjects).go();
|
||||
if (projects.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
cachedProjects,
|
||||
projects.map(_projectToCompanion).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainProjects, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertProject(Project project) async {
|
||||
await into(cachedProjects)
|
||||
.insertOnConflictUpdate(_projectToCompanion(project));
|
||||
}
|
||||
|
||||
Future<void> deleteProject(int id) async {
|
||||
await (delete(cachedProjects)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Milestones ───────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Milestone>> getMilestonesForProject(int projectId) async {
|
||||
final rows = await (select(cachedMilestones)
|
||||
..where((t) => t.projectId.equals(projectId))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.orderIndex)]))
|
||||
.get();
|
||||
return rows.map(_milestoneFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceMilestonesForProject(
|
||||
int projectId,
|
||||
List<Milestone> milestones,
|
||||
) async {
|
||||
await transaction(() async {
|
||||
await (delete(cachedMilestones)
|
||||
..where((t) => t.projectId.equals(projectId)))
|
||||
.go();
|
||||
if (milestones.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
cachedMilestones,
|
||||
milestones.map(_milestoneToCompanion).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainMilestones, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertMilestone(Milestone milestone) async {
|
||||
await into(cachedMilestones)
|
||||
.insertOnConflictUpdate(_milestoneToCompanion(milestone));
|
||||
}
|
||||
|
||||
Future<void> deleteMilestone(int id) async {
|
||||
await (delete(cachedMilestones)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Calendar events ──────────────────────────────────────────────────────
|
||||
|
||||
/// Returns events whose start_dt falls within [from, to). Recurrence
|
||||
/// expansion still happens server-side; a stored event that has a
|
||||
/// non-null recurrence rule will only have one row in cache (its
|
||||
/// canonical instance), so cached date-range queries on recurring
|
||||
/// events are best-effort and may miss future occurrences.
|
||||
Future<List<CalendarEvent>> getEventsInRange(
|
||||
DateTime from, DateTime to) async {
|
||||
final rows = await (select(cachedCalendarEvents)
|
||||
..where((t) =>
|
||||
t.startDt.isBiggerOrEqualValue(from) &
|
||||
t.startDt.isSmallerThanValue(to))
|
||||
..orderBy([(t) => OrderingTerm.asc(t.startDt)]))
|
||||
.get();
|
||||
return rows.map(_eventFromRow).toList();
|
||||
}
|
||||
|
||||
/// Replace the cache for the given range. Events outside [from, to)
|
||||
/// are left alone — this matches the API's range-scoped semantics so
|
||||
/// repeated fetches over disjoint ranges don't clobber each other.
|
||||
Future<void> replaceEventsInRange(
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
List<CalendarEvent> events,
|
||||
) async {
|
||||
await transaction(() async {
|
||||
await (delete(cachedCalendarEvents)
|
||||
..where((t) =>
|
||||
t.startDt.isBiggerOrEqualValue(from) &
|
||||
t.startDt.isSmallerThanValue(to)))
|
||||
.go();
|
||||
if (events.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
cachedCalendarEvents,
|
||||
events.map(_eventToCompanion).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainEvents, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> upsertEvent(CalendarEvent event) async {
|
||||
await into(cachedCalendarEvents)
|
||||
.insertOnConflictUpdate(_eventToCompanion(event));
|
||||
}
|
||||
|
||||
Future<void> deleteEvent(int id) async {
|
||||
await (delete(cachedCalendarEvents)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Conversations (chat list — not messages) ─────────────────────────────
|
||||
|
||||
Future<List<Conversation>> getAllConversations() async {
|
||||
final rows = await (select(cachedConversations)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
.get();
|
||||
return rows.map(_conversationFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceAllConversations(
|
||||
List<Conversation> conversations) async {
|
||||
await transaction(() async {
|
||||
await delete(cachedConversations).go();
|
||||
if (conversations.isNotEmpty) {
|
||||
await batch((b) {
|
||||
b.insertAll(
|
||||
cachedConversations,
|
||||
conversations.map(_conversationToCompanion).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
await _setLastSyncIn(kSyncDomainConversations, DateTime.now());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteConversation(int id) async {
|
||||
await (delete(cachedConversations)..where((t) => t.id.equals(id))).go();
|
||||
}
|
||||
|
||||
// ── Sync metadata ────────────────────────────────────────────────────────
|
||||
|
||||
Future<DateTime?> getLastSync(String domain) async {
|
||||
final row = await (select(syncMetadata)
|
||||
..where((t) => t.domain.equals(domain)))
|
||||
.getSingleOrNull();
|
||||
return row?.lastSyncedAt;
|
||||
}
|
||||
|
||||
/// Most recent sync across all domains. Used by the OfflineBanner so the
|
||||
/// hint reads as "your data was current as of X" regardless of which
|
||||
/// screen the user happens to be on.
|
||||
Future<DateTime?> getLatestSync() async {
|
||||
final row = await (select(syncMetadata)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.lastSyncedAt)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row?.lastSyncedAt;
|
||||
}
|
||||
|
||||
Future<void> _setLastSyncIn(String domain, DateTime timestamp) {
|
||||
return into(syncMetadata).insertOnConflictUpdate(
|
||||
SyncMetadataCompanion(
|
||||
domain: Value(domain),
|
||||
lastSyncedAt: Value(timestamp),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pending writes (Phase 3 offline queue) ───────────────────────────────
|
||||
|
||||
/// Allocate a fresh negative id used as a placeholder for an
|
||||
/// offline-created row until its server id arrives via replay.
|
||||
Future<int> nextTempId() async {
|
||||
final query = customSelect(
|
||||
'SELECT MIN(temp_id) AS m FROM pending_writes WHERE temp_id IS NOT NULL',
|
||||
);
|
||||
final row = await query.getSingleOrNull();
|
||||
final current = row?.read<int?>('m');
|
||||
return (current ?? 0) - 1;
|
||||
}
|
||||
|
||||
Future<int> enqueuePending({
|
||||
required String domain,
|
||||
required String verb,
|
||||
int? targetId,
|
||||
int? tempId,
|
||||
Map<String, dynamic> payload = const {},
|
||||
DateTime? baselineUpdatedAt,
|
||||
}) {
|
||||
return into(pendingWrites).insert(
|
||||
PendingWritesCompanion.insert(
|
||||
domain: domain,
|
||||
verb: verb,
|
||||
targetId: Value(targetId),
|
||||
tempId: Value(tempId),
|
||||
payloadJson: Value(jsonEncode(payload)),
|
||||
baselineUpdatedAt: Value(baselineUpdatedAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Find the queued create-op for an offline-created row so a follow-up
|
||||
/// edit can be coalesced into the original payload (no separate update
|
||||
/// op gets queued). Returns null if no matching create is queued.
|
||||
Future<PendingWrite?> findQueuedCreate(String domain, int tempId) {
|
||||
return (select(pendingWrites)
|
||||
..where((t) =>
|
||||
t.domain.equals(domain) &
|
||||
t.verb.equals(kWriteVerbCreate) &
|
||||
t.tempId.equals(tempId)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> updatePendingPayload(int opId, Map<String, dynamic> payload) {
|
||||
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
|
||||
PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePending(int opId) {
|
||||
return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go();
|
||||
}
|
||||
|
||||
/// Pending ops oldest first — replay order.
|
||||
Future<List<PendingWrite>> listPending() {
|
||||
return (select(pendingWrites)
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<int> queueDepth() async {
|
||||
final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes')
|
||||
.getSingle();
|
||||
return row.read<int>('c');
|
||||
}
|
||||
|
||||
Future<void> markPendingFailed(int opId, String error) {
|
||||
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
|
||||
PendingWritesCompanion(
|
||||
tries: const Value.absent(),
|
||||
lastError: Value(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> incrementPendingTries(int opId) {
|
||||
return customStatement(
|
||||
'UPDATE pending_writes SET tries = tries + 1 WHERE id = ?',
|
||||
[opId],
|
||||
);
|
||||
}
|
||||
|
||||
/// Watch queue depth — used by OfflineBanner for the "Retry (N)" affordance.
|
||||
Stream<int> watchQueueDepth() {
|
||||
final query = customSelect(
|
||||
'SELECT COUNT(*) AS c FROM pending_writes',
|
||||
readsFrom: {pendingWrites},
|
||||
);
|
||||
return query.watchSingle().map((row) => row.read<int>('c'));
|
||||
}
|
||||
|
||||
/// Watch the set of ids in [domain] that have a queued write (either a
|
||||
/// `target_id` for an update/delete or a `temp_id` for an offline create).
|
||||
/// Phase 4 — used by per-row pending-sync indicators in the list views.
|
||||
Stream<Set<int>> watchPendingIds(String domain) {
|
||||
final query = customSelect(
|
||||
'SELECT target_id, temp_id FROM pending_writes WHERE domain = ?',
|
||||
variables: [Variable.withString(domain)],
|
||||
readsFrom: {pendingWrites},
|
||||
);
|
||||
return query.watch().map((rows) {
|
||||
final ids = <int>{};
|
||||
for (final r in rows) {
|
||||
final t = r.read<int?>('target_id');
|
||||
final tmp = r.read<int?>('temp_id');
|
||||
if (t != null) ids.add(t);
|
||||
if (tmp != null) ids.add(tmp);
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Row ↔ model converters ──────────────────────────────────────────────────
|
||||
|
||||
CachedNotesCompanion _noteToCompanion(Note n) => CachedNotesCompanion(
|
||||
id: Value(n.id),
|
||||
title: Value(n.title),
|
||||
body: Value(n.body),
|
||||
tagsJson: Value(jsonEncode(n.tags)),
|
||||
noteType: Value(n.noteType),
|
||||
projectId: Value(n.projectId),
|
||||
milestoneId: Value(n.milestoneId),
|
||||
createdAt: Value(n.createdAt),
|
||||
updatedAt: Value(n.updatedAt),
|
||||
);
|
||||
|
||||
Note _noteFromRow(CachedNote r) => Note(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
body: r.body,
|
||||
tags: (jsonDecode(r.tagsJson) as List<dynamic>).cast<String>(),
|
||||
noteType: r.noteType,
|
||||
projectId: r.projectId,
|
||||
milestoneId: r.milestoneId,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
CachedTasksCompanion _taskToCompanion(Task t) => CachedTasksCompanion(
|
||||
id: Value(t.id),
|
||||
title: Value(t.title),
|
||||
description: Value(t.description),
|
||||
status: Value(t.status.value),
|
||||
priority: Value(t.priority.value),
|
||||
dueDate: Value(t.dueDate),
|
||||
projectId: Value(t.projectId),
|
||||
milestoneId: Value(t.milestoneId),
|
||||
parentId: Value(t.parentId),
|
||||
createdAt: Value(t.createdAt),
|
||||
updatedAt: Value(t.updatedAt),
|
||||
);
|
||||
|
||||
Task _taskFromRow(CachedTask r) => Task(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: TaskStatusExtension.fromString(r.status),
|
||||
priority: TaskPriorityExtension.fromString(r.priority),
|
||||
dueDate: r.dueDate,
|
||||
projectId: r.projectId,
|
||||
milestoneId: r.milestoneId,
|
||||
parentId: r.parentId,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
CachedProjectsCompanion _projectToCompanion(Project p) =>
|
||||
CachedProjectsCompanion(
|
||||
id: Value(p.id),
|
||||
title: Value(p.title),
|
||||
description: Value(p.description),
|
||||
goal: Value(p.goal),
|
||||
status: Value(p.status),
|
||||
color: Value(p.color),
|
||||
autoSummary: Value(p.autoSummary),
|
||||
createdAt: Value(p.createdAt),
|
||||
updatedAt: Value(p.updatedAt),
|
||||
);
|
||||
|
||||
Project _projectFromRow(CachedProject r) => Project(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
goal: r.goal,
|
||||
status: r.status,
|
||||
color: r.color,
|
||||
autoSummary: r.autoSummary,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
CachedMilestonesCompanion _milestoneToCompanion(Milestone m) =>
|
||||
CachedMilestonesCompanion(
|
||||
id: Value(m.id),
|
||||
projectId: Value(m.projectId),
|
||||
title: Value(m.title),
|
||||
description: Value(m.description),
|
||||
status: Value(m.status),
|
||||
orderIndex: Value(m.orderIndex),
|
||||
total: Value(m.total),
|
||||
completed: Value(m.completed),
|
||||
pct: Value(m.pct),
|
||||
createdAt: Value(m.createdAt),
|
||||
updatedAt: Value(m.updatedAt),
|
||||
);
|
||||
|
||||
Milestone _milestoneFromRow(CachedMilestone r) => Milestone(
|
||||
id: r.id,
|
||||
projectId: r.projectId,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: r.status,
|
||||
orderIndex: r.orderIndex,
|
||||
total: r.total,
|
||||
completed: r.completed,
|
||||
pct: r.pct,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
CachedCalendarEventsCompanion _eventToCompanion(CalendarEvent e) =>
|
||||
CachedCalendarEventsCompanion(
|
||||
id: Value(e.id),
|
||||
title: Value(e.title),
|
||||
startDt: Value(e.startDt),
|
||||
endDt: Value(e.endDt),
|
||||
allDay: Value(e.allDay),
|
||||
description: Value(e.description),
|
||||
location: Value(e.location),
|
||||
color: Value(e.color),
|
||||
recurrence: Value(e.recurrence),
|
||||
projectId: Value(e.projectId),
|
||||
reminderMinutes: Value(e.reminderMinutes),
|
||||
);
|
||||
|
||||
CalendarEvent _eventFromRow(CachedCalendarEvent r) => CalendarEvent(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
startDt: r.startDt,
|
||||
endDt: r.endDt,
|
||||
allDay: r.allDay,
|
||||
description: r.description,
|
||||
location: r.location,
|
||||
color: r.color,
|
||||
recurrence: r.recurrence,
|
||||
projectId: r.projectId,
|
||||
reminderMinutes: r.reminderMinutes,
|
||||
);
|
||||
|
||||
CachedConversationsCompanion _conversationToCompanion(Conversation c) =>
|
||||
CachedConversationsCompanion(
|
||||
id: Value(c.id),
|
||||
title: Value(c.title),
|
||||
createdAt: Value(c.createdAt),
|
||||
updatedAt: Value(c.updatedAt),
|
||||
);
|
||||
|
||||
Conversation _conversationFromRow(CachedConversation r) => Conversation(
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
);
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
if (Platform.isAndroid) {
|
||||
await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
|
||||
}
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dir.path, 'fabled_cache.sqlite'));
|
||||
return NativeDatabase.createInBackground(file);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
import 'message.dart';
|
||||
|
||||
class BriefingConversation {
|
||||
final int id;
|
||||
final String title;
|
||||
final String? briefingDate; // YYYY-MM-DD or null
|
||||
final List<Message> messages;
|
||||
|
||||
const BriefingConversation({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.briefingDate,
|
||||
required this.messages,
|
||||
});
|
||||
|
||||
factory BriefingConversation.fromJson(Map<String, dynamic> json) {
|
||||
final rawMessages = json['messages'] as List<dynamic>? ?? [];
|
||||
return BriefingConversation(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
briefingDate: json['briefing_date'] as String?,
|
||||
messages: rawMessages
|
||||
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
BriefingConversation copyWith({List<Message>? messages}) =>
|
||||
BriefingConversation(
|
||||
id: id,
|
||||
title: title,
|
||||
briefingDate: briefingDate,
|
||||
messages: messages ?? this.messages,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
class CalendarEvent {
|
||||
final int id;
|
||||
final String title;
|
||||
final DateTime startDt;
|
||||
final DateTime? endDt;
|
||||
final bool allDay;
|
||||
final String description;
|
||||
final String location;
|
||||
final String color;
|
||||
final String? recurrence;
|
||||
final int? projectId;
|
||||
final int? reminderMinutes;
|
||||
|
||||
const CalendarEvent({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.startDt,
|
||||
this.endDt,
|
||||
required this.allDay,
|
||||
required this.description,
|
||||
required this.location,
|
||||
required this.color,
|
||||
this.recurrence,
|
||||
this.projectId,
|
||||
this.reminderMinutes,
|
||||
});
|
||||
|
||||
factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
startDt: DateTime.parse(json['start_dt'] as String).toLocal(),
|
||||
endDt: json['end_dt'] != null
|
||||
? DateTime.parse(json['end_dt'] as String).toLocal()
|
||||
: null,
|
||||
allDay: json['all_day'] as bool? ?? false,
|
||||
description: json['description'] as String? ?? '',
|
||||
location: json['location'] as String? ?? '',
|
||||
color: json['color'] as String? ?? '',
|
||||
recurrence: json['recurrence'] as String?,
|
||||
projectId: json['project_id'] as int?,
|
||||
reminderMinutes: json['reminder_minutes'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Strips time from a DateTime, returning midnight local.
|
||||
/// Used as map keys in CalendarState.eventsByDay.
|
||||
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'message.dart';
|
||||
|
||||
/// Lightweight conversation header for a journal day — just enough to drive
|
||||
/// navigation and labels. The full message list comes alongside in [JournalDay].
|
||||
class JournalConversation {
|
||||
final int id;
|
||||
final String title;
|
||||
final String conversationType;
|
||||
final String? dayDate; // YYYY-MM-DD or null
|
||||
|
||||
const JournalConversation({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.conversationType,
|
||||
this.dayDate,
|
||||
});
|
||||
|
||||
factory JournalConversation.fromJson(Map<String, dynamic> json) =>
|
||||
JournalConversation(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
conversationType:
|
||||
json['conversation_type'] as String? ?? 'journal',
|
||||
dayDate: json['day_date'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Payload returned by GET /api/journal/today and /api/journal/day/<iso>.
|
||||
/// `conversation` is null on a day with no journal content yet (rare —
|
||||
/// the today endpoint creates it on demand).
|
||||
class JournalDay {
|
||||
final String dayDate;
|
||||
final JournalConversation? conversation;
|
||||
final List<Message> messages;
|
||||
|
||||
const JournalDay({
|
||||
required this.dayDate,
|
||||
required this.conversation,
|
||||
required this.messages,
|
||||
});
|
||||
|
||||
factory JournalDay.fromJson(Map<String, dynamic> json) {
|
||||
final convRaw = json['conversation'] as Map<String, dynamic>?;
|
||||
final rawMessages = json['messages'] as List<dynamic>? ?? [];
|
||||
return JournalDay(
|
||||
dayDate: json['day_date'] as String,
|
||||
conversation:
|
||||
convRaw == null ? null : JournalConversation.fromJson(convRaw),
|
||||
messages: rawMessages
|
||||
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
JournalDay copyWith({List<Message>? messages}) => JournalDay(
|
||||
dayDate: dayDate,
|
||||
conversation: conversation,
|
||||
messages: messages ?? this.messages,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'task.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({
|
||||
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.fromTask(Task task) => KnowledgeItem(
|
||||
id: task.id,
|
||||
noteType: 'task',
|
||||
title: task.title,
|
||||
body: task.description ?? '',
|
||||
tags: const [],
|
||||
projectId: task.projectId,
|
||||
milestoneId: task.milestoneId,
|
||||
parentId: task.parentId,
|
||||
status: task.status.value,
|
||||
priority: task.priority.value,
|
||||
dueDate: task.dueDate?.toIso8601String(),
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
);
|
||||
|
||||
factory KnowledgeItem.fromJson(Map<String, dynamic> json) => KnowledgeItem(
|
||||
id: json['id'] as int,
|
||||
noteType: json['note_type'] as String? ?? 'note',
|
||||
title: json['title'] as String? ?? '',
|
||||
body: (json['snippet'] ?? 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),
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,11 @@ class Message {
|
||||
final String status; // "complete" | "generating"
|
||||
final DateTime? createdAt;
|
||||
final Map<String, dynamic>? metadata;
|
||||
// Tool invocations attached to this message. Each entry matches the shape
|
||||
// persisted by the backend (`function`, `arguments`, `result`, `status`) so
|
||||
// the UI can render the same chips whether they arrive live over SSE or
|
||||
// from a reload.
|
||||
final List<Map<String, dynamic>>? toolCalls;
|
||||
|
||||
const Message({
|
||||
this.id,
|
||||
@@ -17,21 +22,39 @@ class Message {
|
||||
this.status = 'complete',
|
||||
this.createdAt,
|
||||
this.metadata,
|
||||
this.toolCalls,
|
||||
});
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) => Message(
|
||||
id: json['id'] as int?,
|
||||
conversationId: json['conversation_id'] as int,
|
||||
role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant,
|
||||
content: json['content'] as String,
|
||||
status: json['status'] as String? ?? 'complete',
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: null,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
);
|
||||
factory Message.fromJson(Map<String, dynamic> json) {
|
||||
final rawCalls = json['tool_calls'];
|
||||
List<Map<String, dynamic>>? parsedCalls;
|
||||
if (rawCalls is List) {
|
||||
parsedCalls = [
|
||||
for (final tc in rawCalls)
|
||||
if (tc is Map<String, dynamic>) tc,
|
||||
];
|
||||
if (parsedCalls.isEmpty) parsedCalls = null;
|
||||
}
|
||||
return Message(
|
||||
id: json['id'] as int?,
|
||||
conversationId: json['conversation_id'] as int,
|
||||
role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant,
|
||||
content: json['content'] as String,
|
||||
status: json['status'] as String? ?? 'complete',
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: null,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
toolCalls: parsedCalls,
|
||||
);
|
||||
}
|
||||
|
||||
Message copyWith({String? content, String? status}) => Message(
|
||||
Message copyWith({
|
||||
String? content,
|
||||
String? status,
|
||||
List<Map<String, dynamic>>? toolCalls,
|
||||
}) =>
|
||||
Message(
|
||||
id: id,
|
||||
conversationId: conversationId,
|
||||
role: role,
|
||||
@@ -39,5 +62,6 @@ class Message {
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt,
|
||||
metadata: metadata,
|
||||
toolCalls: toolCalls ?? this.toolCalls,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/chat_api.dart';
|
||||
export '../api/chat_api.dart'
|
||||
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
|
||||
import '../local/database.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
/// Chat repository with read-through caching for the conversation list only.
|
||||
/// Messages and the live SSE stream are intentionally not cached — they are
|
||||
/// per-conversation, large, and require a live network anyway.
|
||||
class ChatRepository {
|
||||
final ChatApi _api;
|
||||
const ChatRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
const ChatRepository(this._api, this._db);
|
||||
|
||||
Future<List<Conversation>> getConversations() async {
|
||||
try {
|
||||
final conversations = await _api.getConversations();
|
||||
await _db.replaceAllConversations(conversations);
|
||||
return conversations;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getAllConversations();
|
||||
if (cached.isEmpty) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Conversation>> getConversations() => _api.getConversations();
|
||||
Future<Conversation> createConversation(String title) =>
|
||||
_api.createConversation(title);
|
||||
Future<void> deleteConversation(int id) => _api.deleteConversation(id);
|
||||
|
||||
Future<void> deleteConversation(int id) async {
|
||||
await _api.deleteConversation(id);
|
||||
await _db.deleteConversation(id);
|
||||
}
|
||||
|
||||
Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
|
||||
_api.getMessages(conversationId);
|
||||
|
||||
Future<void> sendMessage(int conversationId, String content) =>
|
||||
_api.sendMessage(conversationId, content);
|
||||
Stream<String> streamGeneration(int conversationId) =>
|
||||
|
||||
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
|
||||
_api.streamGeneration(conversationId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/events_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/calendar_event.dart';
|
||||
|
||||
/// Calendar events repository with read-through caching (Phase 1+2) and
|
||||
/// offline-write queueing (Phase 3). Range-scoped reads: `getEvents(from, to)`
|
||||
/// mirrors that window into the cache (events outside the window are left
|
||||
/// alone) so successive disjoint range fetches don't clobber each other.
|
||||
///
|
||||
/// CalendarEvent has no `updatedAt` field, so write replay uses last-
|
||||
/// writer-wins on update/delete (no server-side baseline check).
|
||||
class EventsRepository {
|
||||
final EventsApi _api;
|
||||
final FabledDatabase _db;
|
||||
|
||||
const EventsRepository(this._api, this._db);
|
||||
|
||||
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
|
||||
try {
|
||||
final events = await _api.getEvents(from, to);
|
||||
await _db.replaceEventsInRange(from, to, events);
|
||||
return events;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getEventsInRange(from, to);
|
||||
if (cached.isEmpty) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
final event = await _api.createEvent(payload);
|
||||
await _db.upsertEvent(event);
|
||||
return event;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final optimistic = _eventFromPayload(tempId, payload);
|
||||
await _db.upsertEvent(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainEvents,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: payload,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<CalendarEvent> updateEvent(
|
||||
int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final updated = await _api.updateEvent(id, fields);
|
||||
await _db.upsertEvent(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = (await _db.getEventsInRange(
|
||||
DateTime.fromMicrosecondsSinceEpoch(0),
|
||||
DateTime.now().add(const Duration(days: 365 * 100)),
|
||||
))
|
||||
.where((e) => e.id == id)
|
||||
.firstOrNull;
|
||||
if (cached == null) rethrow;
|
||||
final optimistic = _applyEventFields(cached, fields);
|
||||
await _db.upsertEvent(optimistic);
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
|
||||
if (queued != null) {
|
||||
await _db.updatePendingPayload(queued.id, fields);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainEvents,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: id,
|
||||
payload: fields,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteEvent(int id) async {
|
||||
try {
|
||||
await _api.deleteEvent(id);
|
||||
await _db.deleteEvent(id);
|
||||
} on NetworkException {
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteEvent(id);
|
||||
return;
|
||||
}
|
||||
await _db.deleteEvent(id);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainEvents,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CalendarEvent _eventFromPayload(int id, Map<String, dynamic> p) {
|
||||
return CalendarEvent(
|
||||
id: id,
|
||||
title: p['title'] as String? ?? '',
|
||||
startDt: _parseIso(p['start_dt'])!,
|
||||
endDt: _parseIso(p['end_dt']),
|
||||
allDay: p['all_day'] as bool? ?? false,
|
||||
description: p['description'] as String? ?? '',
|
||||
location: p['location'] as String? ?? '',
|
||||
color: p['color'] as String? ?? '',
|
||||
recurrence: p['recurrence'] as String?,
|
||||
projectId: p['project_id'] as int?,
|
||||
reminderMinutes: p['reminder_minutes'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
CalendarEvent _applyEventFields(CalendarEvent e, Map<String, dynamic> f) {
|
||||
return CalendarEvent(
|
||||
id: e.id,
|
||||
title: f['title'] as String? ?? e.title,
|
||||
startDt: _parseIso(f['start_dt']) ?? e.startDt,
|
||||
endDt: f.containsKey('end_dt') ? _parseIso(f['end_dt']) : e.endDt,
|
||||
allDay: f['all_day'] as bool? ?? e.allDay,
|
||||
description: f['description'] as String? ?? e.description,
|
||||
location: f['location'] as String? ?? e.location,
|
||||
color: f['color'] as String? ?? e.color,
|
||||
recurrence:
|
||||
f.containsKey('recurrence') ? f['recurrence'] as String? : e.recurrence,
|
||||
projectId: f.containsKey('project_id')
|
||||
? f['project_id'] as int?
|
||||
: e.projectId,
|
||||
reminderMinutes: f.containsKey('reminder_minutes')
|
||||
? f['reminder_minutes'] as int?
|
||||
: e.reminderMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? _parseIso(dynamic raw) {
|
||||
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw)?.toLocal();
|
||||
return null;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,26 +1,164 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/milestones_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/milestone.dart';
|
||||
|
||||
/// Milestones repository with read-through caching (Phase 1+2) and
|
||||
/// offline-write queueing (Phase 3). See `notes_repository.dart` for the
|
||||
/// full pattern.
|
||||
class MilestonesRepository {
|
||||
final MilestonesApi _api;
|
||||
const MilestonesRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
|
||||
_api.getAll(projectId, status: status);
|
||||
const MilestonesRepository(this._api, this._db);
|
||||
|
||||
Future<List<Milestone>> getAll(int projectId, {String? status}) async {
|
||||
try {
|
||||
final milestones = await _api.getAll(projectId, status: status);
|
||||
// Only mirror the full per-project list to cache on the unfiltered
|
||||
// fetch; a status filter would otherwise drop entries from cache.
|
||||
if (status == null) {
|
||||
await _db.replaceMilestonesForProject(projectId, milestones);
|
||||
} else {
|
||||
for (final m in milestones) {
|
||||
await _db.upsertMilestone(m);
|
||||
}
|
||||
}
|
||||
return milestones;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getMilestonesForProject(projectId);
|
||||
if (cached.isEmpty) rethrow;
|
||||
if (status != null) {
|
||||
return cached.where((m) => m.status == status).toList();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Milestone> create(
|
||||
int projectId, {
|
||||
required String title,
|
||||
String? description,
|
||||
int orderIndex = 0,
|
||||
}) =>
|
||||
_api.create(projectId,
|
||||
title: title, description: description, orderIndex: orderIndex);
|
||||
}) async {
|
||||
try {
|
||||
final milestone = await _api.create(
|
||||
projectId,
|
||||
title: title,
|
||||
description: description,
|
||||
orderIndex: orderIndex,
|
||||
);
|
||||
await _db.upsertMilestone(milestone);
|
||||
return milestone;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final now = DateTime.now();
|
||||
final optimistic = Milestone(
|
||||
id: tempId,
|
||||
projectId: projectId,
|
||||
title: title,
|
||||
description: description,
|
||||
status: 'active',
|
||||
orderIndex: orderIndex,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
pct: 0.0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _db.upsertMilestone(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainMilestones,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: {
|
||||
'project_id': projectId,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'order_index': orderIndex,
|
||||
},
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Milestone> update(
|
||||
int projectId, int milestoneId, Map<String, dynamic> fields) =>
|
||||
_api.update(projectId, milestoneId, fields);
|
||||
int projectId,
|
||||
int milestoneId,
|
||||
Map<String, dynamic> fields,
|
||||
) async {
|
||||
try {
|
||||
final updated = await _api.update(projectId, milestoneId, fields);
|
||||
await _db.upsertMilestone(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = (await _db.getMilestonesForProject(projectId))
|
||||
.where((m) => m.id == milestoneId)
|
||||
.firstOrNull;
|
||||
if (cached == null) rethrow;
|
||||
final optimistic = _applyMilestoneFields(cached, fields);
|
||||
await _db.upsertMilestone(optimistic);
|
||||
if (milestoneId < 0) {
|
||||
final queued =
|
||||
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
|
||||
if (queued != null) {
|
||||
// Carry project_id through coalesced payload — replay needs it.
|
||||
final merged = <String, dynamic>{
|
||||
'project_id': projectId,
|
||||
...fields,
|
||||
};
|
||||
await _db.updatePendingPayload(queued.id, merged);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainMilestones,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: milestoneId,
|
||||
payload: <String, dynamic>{'project_id': projectId, ...fields},
|
||||
baselineUpdatedAt: cached.updatedAt,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int projectId, int milestoneId) =>
|
||||
_api.delete(projectId, milestoneId);
|
||||
Future<void> delete(int projectId, int milestoneId) async {
|
||||
try {
|
||||
await _api.delete(projectId, milestoneId);
|
||||
await _db.deleteMilestone(milestoneId);
|
||||
} on NetworkException {
|
||||
if (milestoneId < 0) {
|
||||
final queued =
|
||||
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteMilestone(milestoneId);
|
||||
return;
|
||||
}
|
||||
await _db.deleteMilestone(milestoneId);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainMilestones,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: milestoneId,
|
||||
payload: {'project_id': projectId},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Milestone _applyMilestoneFields(Milestone m, Map<String, dynamic> f) {
|
||||
return Milestone(
|
||||
id: m.id,
|
||||
projectId: m.projectId,
|
||||
title: f['title'] as String? ?? m.title,
|
||||
description: f.containsKey('description')
|
||||
? f['description'] as String?
|
||||
: m.description,
|
||||
status: f['status'] as String? ?? m.status,
|
||||
orderIndex: f['order_index'] as int? ?? m.orderIndex,
|
||||
total: m.total,
|
||||
completed: m.completed,
|
||||
pct: m.pct,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,100 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/notes_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/note.dart';
|
||||
|
||||
/// Notes repository with read-through caching for Tier 2 offline support
|
||||
/// (Phase 1+2) and offline-write queueing (Phase 3).
|
||||
///
|
||||
/// Reads attempt the network first; on success the response is written to
|
||||
/// the local Drift cache. On `NetworkException` the read falls back to the
|
||||
/// cache (rethrowing if the cache is empty so the UI can show its
|
||||
/// fresh-install empty state).
|
||||
///
|
||||
/// Writes hit the server then sync the cache. On `NetworkException` the
|
||||
/// write is queued in `pending_writes` and applied optimistically to the
|
||||
/// cache (negative temp-id for creates, in-place update for edits, removal
|
||||
/// for deletes). Subsequent edits to a temp-id row are coalesced into the
|
||||
/// queued create — only one server call is made per offline-created row.
|
||||
///
|
||||
/// `AuthStatus.offline` is owned by `AuthNotifier.verify()` (a periodic
|
||||
/// heartbeat). This repository deliberately does not poke that state.
|
||||
class NotesRepository {
|
||||
final NotesApi _api;
|
||||
const NotesRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
Future<List<Note>> getAll() => _api.getAll();
|
||||
Future<Note> getOne(int id) => _api.getOne(id);
|
||||
const NotesRepository(this._api, this._db);
|
||||
|
||||
Future<List<Note>> getAll() async {
|
||||
try {
|
||||
final notes = await _api.getAll();
|
||||
await _db.replaceAllNotes(notes);
|
||||
return notes;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getAllNotes();
|
||||
if (cached.isEmpty) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> getOne(int id) async {
|
||||
try {
|
||||
final note = await _api.getOne(id);
|
||||
await _db.upsertNote(note);
|
||||
return note;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getNote(id);
|
||||
if (cached == null) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> create(
|
||||
String title,
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
}) =>
|
||||
_api.create(title, body, tags: tags, projectId: projectId);
|
||||
String noteType = 'note',
|
||||
}) async {
|
||||
try {
|
||||
final note = await _api.create(
|
||||
title,
|
||||
body,
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
noteType: noteType,
|
||||
);
|
||||
await _db.upsertNote(note);
|
||||
return note;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final now = DateTime.now();
|
||||
final optimistic = Note(
|
||||
id: tempId,
|
||||
title: title,
|
||||
body: body,
|
||||
tags: tags,
|
||||
noteType: noteType,
|
||||
projectId: projectId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _db.upsertNote(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainNotes,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'tags': tags,
|
||||
'project_id': projectId,
|
||||
'note_type': noteType,
|
||||
},
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> update(
|
||||
int id,
|
||||
@@ -23,9 +103,77 @@ class NotesRepository {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
bool clearProject = false,
|
||||
}) =>
|
||||
_api.update(id, title, body,
|
||||
tags: tags, projectId: projectId, clearProject: clearProject);
|
||||
String noteType = 'note',
|
||||
}) async {
|
||||
try {
|
||||
final updated = await _api.update(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
clearProject: clearProject,
|
||||
noteType: noteType,
|
||||
);
|
||||
await _db.upsertNote(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getNote(id);
|
||||
if (cached == null) rethrow;
|
||||
final payload = <String, dynamic>{
|
||||
'title': title,
|
||||
'body': body,
|
||||
'tags': tags,
|
||||
'project_id': projectId,
|
||||
'clear_project': clearProject,
|
||||
'note_type': noteType,
|
||||
};
|
||||
final optimistic = cached.copyWith(
|
||||
title: title,
|
||||
body: body,
|
||||
tags: tags,
|
||||
noteType: noteType,
|
||||
projectId: clearProject ? null : projectId,
|
||||
);
|
||||
await _db.upsertNote(optimistic);
|
||||
// Edits to an offline-created row coalesce into the queued create.
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
|
||||
if (queued != null) {
|
||||
await _db.updatePendingPayload(queued.id, payload);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainNotes,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: id,
|
||||
payload: payload,
|
||||
baselineUpdatedAt: cached.updatedAt,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _api.delete(id);
|
||||
await _db.deleteNote(id);
|
||||
} on NetworkException {
|
||||
// Deleting an offline-created row that never reached the server
|
||||
// just drops the queued create — no server call needed.
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteNote(id);
|
||||
return;
|
||||
}
|
||||
await _db.deleteNote(id);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainNotes,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,168 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/projects_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/project.dart';
|
||||
|
||||
/// Projects repository with read-through caching (Phase 1+2) and offline-write
|
||||
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
|
||||
///
|
||||
/// `getAll`'s sort/order/status query parameters are server-side filters; the
|
||||
/// cache stores the unfiltered list as last seen. Offline fallback returns
|
||||
/// the full cache regardless of the requested filters — close enough for a
|
||||
/// "view what you had" experience while disconnected.
|
||||
class ProjectsRepository {
|
||||
final ProjectsApi _api;
|
||||
const ProjectsRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
const ProjectsRepository(this._api, this._db);
|
||||
|
||||
Future<List<Project>> getAll({
|
||||
String? status,
|
||||
String sort = 'updated_at',
|
||||
String order = 'desc',
|
||||
}) async {
|
||||
try {
|
||||
final projects =
|
||||
await _api.getAll(status: status, sort: sort, order: order);
|
||||
// Only refresh the cache on the unfiltered default fetch — otherwise a
|
||||
// status=archived call would clobber the active-projects cache.
|
||||
if (status == null) {
|
||||
await _db.replaceAllProjects(projects);
|
||||
} else {
|
||||
for (final p in projects) {
|
||||
await _db.upsertProject(p);
|
||||
}
|
||||
}
|
||||
return projects;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getAllProjects();
|
||||
if (cached.isEmpty) rethrow;
|
||||
if (status != null) {
|
||||
return cached.where((p) => p.status == status).toList();
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> getOne(int id) async {
|
||||
try {
|
||||
final project = await _api.getOne(id);
|
||||
await _db.upsertProject(project);
|
||||
return project;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getProject(id);
|
||||
if (cached == null) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Project>> getAll({String? status}) => _api.getAll(status: status);
|
||||
Future<Project> getOne(int id) => _api.getOne(id);
|
||||
Future<Project> create({
|
||||
required String title,
|
||||
String? description,
|
||||
String? goal,
|
||||
String? color,
|
||||
}) =>
|
||||
_api.create(
|
||||
title: title, description: description, goal: goal, color: color);
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
||||
_api.update(id, fields);
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
String status = 'active',
|
||||
}) async {
|
||||
try {
|
||||
final project = await _api.create(
|
||||
title: title,
|
||||
description: description,
|
||||
goal: goal,
|
||||
color: color,
|
||||
status: status,
|
||||
);
|
||||
await _db.upsertProject(project);
|
||||
return project;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final now = DateTime.now();
|
||||
final optimistic = Project(
|
||||
id: tempId,
|
||||
title: title,
|
||||
description: description,
|
||||
goal: goal,
|
||||
status: status,
|
||||
color: color,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _db.upsertProject(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainProjects,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'goal': goal,
|
||||
'color': color,
|
||||
'status': status,
|
||||
},
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final updated = await _api.update(id, fields);
|
||||
await _db.upsertProject(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getProject(id);
|
||||
if (cached == null) rethrow;
|
||||
final optimistic = _applyProjectFields(cached, fields);
|
||||
await _db.upsertProject(optimistic);
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
|
||||
if (queued != null) {
|
||||
await _db.updatePendingPayload(queued.id, fields);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainProjects,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: id,
|
||||
payload: fields,
|
||||
baselineUpdatedAt: cached.updatedAt,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _api.delete(id);
|
||||
await _db.deleteProject(id);
|
||||
} on NetworkException {
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteProject(id);
|
||||
return;
|
||||
}
|
||||
await _db.deleteProject(id);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainProjects,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Project _applyProjectFields(Project p, Map<String, dynamic> f) {
|
||||
return Project(
|
||||
id: p.id,
|
||||
title: f['title'] as String? ?? p.title,
|
||||
description:
|
||||
f.containsKey('description') ? f['description'] as String? : p.description,
|
||||
goal: f.containsKey('goal') ? f['goal'] as String? : p.goal,
|
||||
status: f['status'] as String? ?? p.status,
|
||||
color: f.containsKey('color') ? f['color'] as String? : p.color,
|
||||
autoSummary: p.autoSummary,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,63 @@
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/tasks_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/task.dart';
|
||||
|
||||
/// Tasks repository with read-through caching (Phase 1+2) and offline-write
|
||||
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
|
||||
class TasksRepository {
|
||||
final TasksApi _api;
|
||||
const TasksRepository(this._api);
|
||||
final FabledDatabase _db;
|
||||
|
||||
Future<List<Task>> getAll() => _api.getAll();
|
||||
Future<Task> getOne(int id) => _api.getOne(id);
|
||||
const TasksRepository(this._api, this._db);
|
||||
|
||||
Future<List<Task>> getAll() async {
|
||||
try {
|
||||
final tasks = await _api.getAll();
|
||||
await _db.replaceAllTasks(tasks);
|
||||
return tasks;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getAllTasks();
|
||||
if (cached.isEmpty) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Task> getOne(int id) async {
|
||||
try {
|
||||
final task = await _api.getOne(id);
|
||||
await _db.upsertTask(task);
|
||||
return task;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getTask(id);
|
||||
if (cached == null) rethrow;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getByProject(int projectId) async {
|
||||
try {
|
||||
final tasks = await _api.getByProject(projectId);
|
||||
for (final t in tasks) {
|
||||
await _db.upsertTask(t);
|
||||
}
|
||||
return tasks;
|
||||
} on NetworkException {
|
||||
return _db.getTasksByProject(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getSubTasks(int parentId) async {
|
||||
try {
|
||||
final tasks = await _api.getSubTasks(parentId);
|
||||
for (final t in tasks) {
|
||||
await _db.upsertTask(t);
|
||||
}
|
||||
return tasks;
|
||||
} on NetworkException {
|
||||
return _db.getSubTasks(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Task> create({
|
||||
required String title,
|
||||
@@ -16,8 +67,9 @@ class TasksRepository {
|
||||
DateTime? dueDate,
|
||||
int? projectId,
|
||||
int? parentId,
|
||||
}) =>
|
||||
_api.create(
|
||||
}) async {
|
||||
try {
|
||||
final task = await _api.create(
|
||||
title: title,
|
||||
description: description,
|
||||
status: status,
|
||||
@@ -26,12 +78,119 @@ class TasksRepository {
|
||||
projectId: projectId,
|
||||
parentId: parentId,
|
||||
);
|
||||
await _db.upsertTask(task);
|
||||
return task;
|
||||
} on NetworkException {
|
||||
final tempId = await _db.nextTempId();
|
||||
final now = DateTime.now();
|
||||
final optimistic = Task(
|
||||
id: tempId,
|
||||
title: title,
|
||||
description: description,
|
||||
status: status,
|
||||
priority: priority,
|
||||
dueDate: dueDate,
|
||||
projectId: projectId,
|
||||
parentId: parentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _db.upsertTask(optimistic);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainTasks,
|
||||
verb: kWriteVerbCreate,
|
||||
tempId: tempId,
|
||||
payload: {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'status': status.value,
|
||||
'priority': priority.value,
|
||||
'due_date': dueDate?.toIso8601String(),
|
||||
'project_id': projectId,
|
||||
'parent_id': parentId,
|
||||
},
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
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) async {
|
||||
try {
|
||||
final updated = await _api.update(id, fields);
|
||||
await _db.upsertTask(updated);
|
||||
return updated;
|
||||
} on NetworkException {
|
||||
final cached = await _db.getTask(id);
|
||||
if (cached == null) rethrow;
|
||||
final optimistic = _applyTaskFields(cached, fields);
|
||||
await _db.upsertTask(optimistic);
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
|
||||
if (queued != null) {
|
||||
await _db.updatePendingPayload(queued.id, fields);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainTasks,
|
||||
verb: kWriteVerbUpdate,
|
||||
targetId: id,
|
||||
payload: fields,
|
||||
baselineUpdatedAt: cached.updatedAt,
|
||||
);
|
||||
return optimistic;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
||||
_api.update(id, fields);
|
||||
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _api.delete(id);
|
||||
await _db.deleteTask(id);
|
||||
} on NetworkException {
|
||||
if (id < 0) {
|
||||
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
|
||||
if (queued != null) await _db.deletePending(queued.id);
|
||||
await _db.deleteTask(id);
|
||||
return;
|
||||
}
|
||||
await _db.deleteTask(id);
|
||||
await _db.enqueuePending(
|
||||
domain: kSyncDomainTasks,
|
||||
verb: kWriteVerbDelete,
|
||||
targetId: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a partial-fields map (the same shape sent to the PUT endpoint)
|
||||
/// to a cached Task so the UI can show the edit immediately.
|
||||
Task _applyTaskFields(Task t, Map<String, dynamic> f) {
|
||||
return Task(
|
||||
id: t.id,
|
||||
title: f['title'] as String? ?? t.title,
|
||||
description:
|
||||
f.containsKey('body') ? f['body'] as String? : t.description,
|
||||
status: f.containsKey('status')
|
||||
? TaskStatusExtension.fromString(f['status'] as String?)
|
||||
: t.status,
|
||||
priority: f.containsKey('priority')
|
||||
? TaskPriorityExtension.fromString(f['priority'] as String?)
|
||||
: t.priority,
|
||||
dueDate: f.containsKey('due_date')
|
||||
? (f['due_date'] is String
|
||||
? DateTime.tryParse(f['due_date'] as String)
|
||||
: null)
|
||||
: t.dueDate,
|
||||
projectId: f.containsKey('project_id')
|
||||
? f['project_id'] as int?
|
||||
: t.projectId,
|
||||
milestoneId: f.containsKey('milestone_id')
|
||||
? f['milestone_id'] as int?
|
||||
: t.milestoneId,
|
||||
parentId:
|
||||
f.containsKey('parent_id') ? f['parent_id'] as int? : t.parentId,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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, {String? context}) =>
|
||||
_api.transcribe(audioBytes, context: context);
|
||||
Future<Uint8List> synthesise(String text) => _api.synthesise(text);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../api/events_api.dart';
|
||||
import '../api/milestones_api.dart';
|
||||
import '../api/notes_api.dart';
|
||||
import '../api/projects_api.dart';
|
||||
import '../api/tasks_api.dart';
|
||||
import '../local/database.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/project.dart';
|
||||
import '../models/task.dart';
|
||||
|
||||
/// Reasons a queued write was dropped during replay. Surfaced to the UI as
|
||||
/// a one-time SnackBar so the user knows their offline edit didn't land.
|
||||
enum QueueFailureReason { overwritten, missing, rejected }
|
||||
|
||||
class QueueFailure {
|
||||
final QueueFailureReason reason;
|
||||
final String domain;
|
||||
final String? title;
|
||||
final String? detail;
|
||||
const QueueFailure({
|
||||
required this.reason,
|
||||
required this.domain,
|
||||
this.title,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
String get message {
|
||||
final label = title?.isNotEmpty == true ? '"$title"' : 'an offline edit';
|
||||
return switch (reason) {
|
||||
QueueFailureReason.overwritten =>
|
||||
'$label was overwritten by a newer change on the server.',
|
||||
QueueFailureReason.missing =>
|
||||
'$label was deleted on the server before your offline edit could be saved.',
|
||||
QueueFailureReason.rejected =>
|
||||
'Failed to save $label: ${detail ?? 'rejected by server.'}',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Drains the offline write queue (Phase 3). Owns the network → server
|
||||
/// dispatch logic for every queued op; repos write to `pending_writes`,
|
||||
/// this service replays them.
|
||||
///
|
||||
/// Replay semantics:
|
||||
/// - oldest-first, single in-flight `replay()` call
|
||||
/// - on `NetworkException` (or 5xx): leave op in place, stop the loop
|
||||
/// - on conflict (server `updated_at` newer than op baseline): drop +
|
||||
/// surface as `overwritten`
|
||||
/// - on 404: for delete → silent success; for update → drop + `missing`
|
||||
/// - on other 4xx: drop + `rejected`
|
||||
class WriteQueue {
|
||||
final FabledDatabase _db;
|
||||
final NotesApi _notesApi;
|
||||
final TasksApi _tasksApi;
|
||||
final ProjectsApi _projectsApi;
|
||||
final MilestonesApi _milestonesApi;
|
||||
final EventsApi _eventsApi;
|
||||
|
||||
final StreamController<QueueFailure> _failures =
|
||||
StreamController<QueueFailure>.broadcast();
|
||||
bool _replaying = false;
|
||||
|
||||
WriteQueue({
|
||||
required FabledDatabase db,
|
||||
required NotesApi notesApi,
|
||||
required TasksApi tasksApi,
|
||||
required ProjectsApi projectsApi,
|
||||
required MilestonesApi milestonesApi,
|
||||
required EventsApi eventsApi,
|
||||
}) : _db = db,
|
||||
_notesApi = notesApi,
|
||||
_tasksApi = tasksApi,
|
||||
_projectsApi = projectsApi,
|
||||
_milestonesApi = milestonesApi,
|
||||
_eventsApi = eventsApi;
|
||||
|
||||
Stream<QueueFailure> get failures => _failures.stream;
|
||||
|
||||
void dispose() => _failures.close();
|
||||
|
||||
/// Drain the queue. Reentrant calls are no-ops while a replay is in
|
||||
/// flight — the in-flight one will see new entries on its next iteration.
|
||||
Future<void> replay() async {
|
||||
if (_replaying) return;
|
||||
_replaying = true;
|
||||
try {
|
||||
while (true) {
|
||||
final ops = await _db.listPending();
|
||||
if (ops.isEmpty) break;
|
||||
final op = ops.first;
|
||||
final keepGoing = await _replayOne(op);
|
||||
if (!keepGoing) break;
|
||||
}
|
||||
} finally {
|
||||
_replaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _replayOne(PendingWrite op) async {
|
||||
final payload = jsonDecode(op.payloadJson) as Map<String, dynamic>;
|
||||
await _db.incrementPendingTries(op.id);
|
||||
try {
|
||||
switch (op.domain) {
|
||||
case kSyncDomainNotes:
|
||||
await _replayNote(op, payload);
|
||||
case kSyncDomainTasks:
|
||||
await _replayTask(op, payload);
|
||||
case kSyncDomainProjects:
|
||||
await _replayProject(op, payload);
|
||||
case kSyncDomainMilestones:
|
||||
await _replayMilestone(op, payload);
|
||||
case kSyncDomainEvents:
|
||||
await _replayEvent(op, payload);
|
||||
default:
|
||||
// Unknown domain — drop so we don't loop forever.
|
||||
await _db.deletePending(op.id);
|
||||
return true;
|
||||
}
|
||||
await _db.deletePending(op.id);
|
||||
return true;
|
||||
} on NetworkException catch (e) {
|
||||
// Still offline — leave op for next replay trigger.
|
||||
await _db.markPendingFailed(op.id, e.message);
|
||||
return false;
|
||||
} on ServerException catch (e) {
|
||||
if (e.statusCode >= 500) {
|
||||
// Likely transient — keep + stop the loop, retry next time.
|
||||
await _db.markPendingFailed(op.id, e.message);
|
||||
return false;
|
||||
}
|
||||
// 4xx — non-retryable. Drop + surface.
|
||||
await _db.deletePending(op.id);
|
||||
_failures.add(QueueFailure(
|
||||
reason: QueueFailureReason.rejected,
|
||||
domain: op.domain,
|
||||
title: payload['title'] as String?,
|
||||
detail: e.message,
|
||||
));
|
||||
return true;
|
||||
} on NotFoundException {
|
||||
// Target gone server-side. Update → tell user; delete → silent success.
|
||||
await _db.deletePending(op.id);
|
||||
if (op.verb != kWriteVerbDelete) {
|
||||
_failures.add(QueueFailure(
|
||||
reason: QueueFailureReason.missing,
|
||||
domain: op.domain,
|
||||
title: payload['title'] as String?,
|
||||
));
|
||||
}
|
||||
// Clean the cache so the optimistic row doesn't linger.
|
||||
await _evictCache(op.domain, op.targetId ?? op.tempId);
|
||||
return true;
|
||||
} on _OverwrittenSignal catch (s) {
|
||||
await _db.deletePending(op.id);
|
||||
_failures.add(QueueFailure(
|
||||
reason: QueueFailureReason.overwritten,
|
||||
domain: op.domain,
|
||||
title: s.title,
|
||||
));
|
||||
return true;
|
||||
} on AppException catch (e) {
|
||||
// Auth or other — surface and drop so the queue can drain.
|
||||
await _db.deletePending(op.id);
|
||||
_failures.add(QueueFailure(
|
||||
reason: QueueFailureReason.rejected,
|
||||
domain: op.domain,
|
||||
title: payload['title'] as String?,
|
||||
detail: e.message,
|
||||
));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _evictCache(String domain, int? id) async {
|
||||
if (id == null) return;
|
||||
switch (domain) {
|
||||
case kSyncDomainNotes:
|
||||
await _db.deleteNote(id);
|
||||
case kSyncDomainTasks:
|
||||
await _db.deleteTask(id);
|
||||
case kSyncDomainProjects:
|
||||
await _db.deleteProject(id);
|
||||
case kSyncDomainMilestones:
|
||||
await _db.deleteMilestone(id);
|
||||
case kSyncDomainEvents:
|
||||
await _db.deleteEvent(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayNote(PendingWrite op, Map<String, dynamic> p) async {
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final note = await _notesApi.create(
|
||||
p['title'] as String? ?? '',
|
||||
p['body'] as String? ?? '',
|
||||
tags: _stringList(p['tags']),
|
||||
projectId: p['project_id'] as int?,
|
||||
noteType: p['note_type'] as String? ?? 'note',
|
||||
);
|
||||
if (op.tempId != null) await _db.deleteNote(op.tempId!);
|
||||
await _db.upsertNote(note);
|
||||
case kWriteVerbUpdate:
|
||||
await _conflictGuard<Note>(
|
||||
baseline: op.baselineUpdatedAt,
|
||||
fetch: () => _notesApi.getOne(op.targetId!),
|
||||
updatedAt: (n) => n.updatedAt,
|
||||
title: (n) => n.title,
|
||||
syncCache: (n) => _db.upsertNote(n),
|
||||
);
|
||||
final note = await _notesApi.update(
|
||||
op.targetId!,
|
||||
p['title'] as String? ?? '',
|
||||
p['body'] as String? ?? '',
|
||||
tags: _stringList(p['tags']),
|
||||
projectId: p['project_id'] as int?,
|
||||
clearProject: p['clear_project'] as bool? ?? false,
|
||||
noteType: p['note_type'] as String? ?? 'note',
|
||||
);
|
||||
await _db.upsertNote(note);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _notesApi.delete(op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteNote(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tasks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayTask(PendingWrite op, Map<String, dynamic> p) async {
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final task = await _tasksApi.create(
|
||||
title: p['title'] as String? ?? '',
|
||||
description: p['description'] as String?,
|
||||
status: TaskStatusExtension.fromString(p['status'] as String?),
|
||||
priority: TaskPriorityExtension.fromString(p['priority'] as String?),
|
||||
dueDate: _parseDate(p['due_date']),
|
||||
projectId: p['project_id'] as int?,
|
||||
parentId: p['parent_id'] as int?,
|
||||
);
|
||||
if (op.tempId != null) await _db.deleteTask(op.tempId!);
|
||||
await _db.upsertTask(task);
|
||||
case kWriteVerbUpdate:
|
||||
await _conflictGuard<Task>(
|
||||
baseline: op.baselineUpdatedAt,
|
||||
fetch: () => _tasksApi.getOne(op.targetId!),
|
||||
updatedAt: (t) => t.updatedAt,
|
||||
title: (t) => t.title,
|
||||
syncCache: (t) => _db.upsertTask(t),
|
||||
);
|
||||
final task = await _tasksApi.update(op.targetId!, p);
|
||||
await _db.upsertTask(task);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _tasksApi.delete(op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteTask(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Projects ───────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayProject(PendingWrite op, Map<String, dynamic> p) async {
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final project = await _projectsApi.create(
|
||||
title: p['title'] as String? ?? '',
|
||||
description: p['description'] as String?,
|
||||
goal: p['goal'] as String?,
|
||||
color: p['color'] as String?,
|
||||
status: p['status'] as String? ?? 'active',
|
||||
);
|
||||
if (op.tempId != null) await _db.deleteProject(op.tempId!);
|
||||
await _db.upsertProject(project);
|
||||
case kWriteVerbUpdate:
|
||||
await _conflictGuard<Project>(
|
||||
baseline: op.baselineUpdatedAt,
|
||||
fetch: () => _projectsApi.getOne(op.targetId!),
|
||||
updatedAt: (proj) => proj.updatedAt,
|
||||
title: (proj) => proj.title,
|
||||
syncCache: (proj) => _db.upsertProject(proj),
|
||||
);
|
||||
final project = await _projectsApi.update(op.targetId!, p);
|
||||
await _db.upsertProject(project);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _projectsApi.delete(op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteProject(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Milestones ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayMilestone(
|
||||
PendingWrite op, Map<String, dynamic> p) async {
|
||||
final projectId = p['project_id'] as int;
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final milestone = await _milestonesApi.create(
|
||||
projectId,
|
||||
title: p['title'] as String? ?? '',
|
||||
description: p['description'] as String?,
|
||||
orderIndex: p['order_index'] as int? ?? 0,
|
||||
);
|
||||
if (op.tempId != null) await _db.deleteMilestone(op.tempId!);
|
||||
await _db.upsertMilestone(milestone);
|
||||
case kWriteVerbUpdate:
|
||||
// Milestones API has no getOne — skip the conflict pre-check and
|
||||
// let the PUT apply unconditionally. If two clients fight over the
|
||||
// same milestone, last-writer-wins is acceptable here.
|
||||
final milestone = await _milestonesApi.update(
|
||||
projectId,
|
||||
op.targetId!,
|
||||
Map<String, dynamic>.from(p)..remove('project_id'),
|
||||
);
|
||||
await _db.upsertMilestone(milestone);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _milestonesApi.delete(projectId, op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteMilestone(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _replayEvent(PendingWrite op, Map<String, dynamic> p) async {
|
||||
switch (op.verb) {
|
||||
case kWriteVerbCreate:
|
||||
final event = await _eventsApi.createEvent(p);
|
||||
if (op.tempId != null) await _db.deleteEvent(op.tempId!);
|
||||
await _db.upsertEvent(event);
|
||||
case kWriteVerbUpdate:
|
||||
// Events API also has no getOne — skip the pre-check; last-writer-wins.
|
||||
final event = await _eventsApi.updateEvent(op.targetId!, p);
|
||||
await _db.upsertEvent(event);
|
||||
case kWriteVerbDelete:
|
||||
try {
|
||||
await _eventsApi.deleteEvent(op.targetId!);
|
||||
} on NotFoundException {/* already gone */}
|
||||
await _db.deleteEvent(op.targetId!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Server-wins conflict guard. Fetch the target, compare its `updated_at`
|
||||
/// against the baseline captured when the user made the offline edit;
|
||||
/// if the server is newer, sync cache and signal the caller to drop.
|
||||
Future<void> _conflictGuard<T>({
|
||||
required DateTime? baseline,
|
||||
required Future<T> Function() fetch,
|
||||
required DateTime Function(T) updatedAt,
|
||||
required String Function(T) title,
|
||||
required Future<void> Function(T) syncCache,
|
||||
}) async {
|
||||
if (baseline == null) return;
|
||||
final server = await fetch();
|
||||
if (updatedAt(server).isAfter(baseline)) {
|
||||
await syncCache(server);
|
||||
throw _OverwrittenSignal(title(server));
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _stringList(dynamic raw) =>
|
||||
raw is List ? raw.map((e) => e.toString()).toList() : const [];
|
||||
|
||||
DateTime? _parseDate(dynamic raw) {
|
||||
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _OverwrittenSignal implements Exception {
|
||||
final String title;
|
||||
const _OverwrittenSignal(this.title);
|
||||
}
|
||||
@@ -4,20 +4,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
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/journal_api.dart';
|
||||
import '../data/api/knowledge_api.dart';
|
||||
import '../data/api/voice_api.dart';
|
||||
import '../data/api/milestones_api.dart';
|
||||
import '../data/api/events_api.dart';
|
||||
import '../data/api/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/local/database.dart';
|
||||
import '../data/repositories/auth_repository.dart';
|
||||
import '../data/repositories/chat_repository.dart';
|
||||
import '../data/repositories/events_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';
|
||||
import '../data/repositories/tasks_repository.dart';
|
||||
import '../data/repositories/write_queue.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
final cookieJarProvider = Provider<PersistCookieJar>((ref) {
|
||||
@@ -55,24 +63,45 @@ final projectsApiProvider = Provider<ProjectsApi>((ref) {
|
||||
return ProjectsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
/// Local SQLite cache used by repositories for read-through caching and
|
||||
/// offline fallback. Backed by Drift; lives for the lifetime of the
|
||||
/// ProviderScope and is closed automatically when the scope disposes.
|
||||
final fabledDatabaseProvider = Provider<FabledDatabase>((ref) {
|
||||
final db = FabledDatabase();
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
});
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return AuthRepository(ref.watch(authApiProvider));
|
||||
});
|
||||
|
||||
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
|
||||
return NotesRepository(ref.watch(notesApiProvider));
|
||||
return NotesRepository(
|
||||
ref.watch(notesApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
||||
return TasksRepository(ref.watch(tasksApiProvider));
|
||||
return TasksRepository(
|
||||
ref.watch(tasksApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||
return ChatRepository(ref.watch(chatApiProvider));
|
||||
return ChatRepository(
|
||||
ref.watch(chatApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||
return ProjectsRepository(ref.watch(projectsApiProvider));
|
||||
return ProjectsRepository(
|
||||
ref.watch(projectsApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||
@@ -80,13 +109,84 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||
});
|
||||
|
||||
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
||||
return MilestonesRepository(
|
||||
ref.watch(milestonesApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
||||
return BriefingApi(ref.watch(dioProvider));
|
||||
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
|
||||
return KnowledgeApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
|
||||
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
|
||||
});
|
||||
|
||||
final journalApiProvider = Provider<JournalApi>((ref) {
|
||||
return JournalApi(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));
|
||||
});
|
||||
|
||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||
return EventsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final eventsRepositoryProvider = Provider<EventsRepository>((ref) {
|
||||
return EventsRepository(
|
||||
ref.watch(eventsApiProvider),
|
||||
ref.watch(fabledDatabaseProvider),
|
||||
);
|
||||
});
|
||||
|
||||
/// Phase 3 — drains the offline write queue. Listen on
|
||||
/// `writeQueueFailuresProvider` to surface dropped ops to the user;
|
||||
/// `writeQueueDepthProvider` powers the OfflineBanner's "Retry (N)" affordance.
|
||||
final writeQueueProvider = Provider<WriteQueue>((ref) {
|
||||
final queue = WriteQueue(
|
||||
db: ref.watch(fabledDatabaseProvider),
|
||||
notesApi: ref.watch(notesApiProvider),
|
||||
tasksApi: ref.watch(tasksApiProvider),
|
||||
projectsApi: ref.watch(projectsApiProvider),
|
||||
milestonesApi: ref.watch(milestonesApiProvider),
|
||||
eventsApi: ref.watch(eventsApiProvider),
|
||||
);
|
||||
ref.onDispose(queue.dispose);
|
||||
return queue;
|
||||
});
|
||||
|
||||
final writeQueueDepthProvider = StreamProvider<int>((ref) {
|
||||
return ref.watch(fabledDatabaseProvider).watchQueueDepth();
|
||||
});
|
||||
|
||||
final writeQueueFailuresProvider = StreamProvider<QueueFailure>((ref) {
|
||||
return ref.watch(writeQueueProvider).failures;
|
||||
});
|
||||
|
||||
/// Per-domain set of ids with a queued write (target_id ∪ temp_id). Phase 4
|
||||
/// — used by `PendingSyncBadge` in list views to mark rows that are still
|
||||
/// in flight.
|
||||
final pendingNoteIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainNotes);
|
||||
});
|
||||
|
||||
final pendingTaskIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainTasks);
|
||||
});
|
||||
|
||||
final pendingProjectIdsProvider = StreamProvider<Set<int>>((ref) {
|
||||
return ref
|
||||
.watch(fabledDatabaseProvider)
|
||||
.watchPendingIds(kSyncDomainProjects);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/exceptions.dart';
|
||||
import 'api_client_provider.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||
enum AuthStatus { unknown, authenticated, unauthenticated, offline }
|
||||
|
||||
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
|
||||
|
||||
@@ -14,7 +16,12 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
||||
try {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
final ok = await repo.verify();
|
||||
if (ok) {
|
||||
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||
}
|
||||
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
||||
} on NetworkException {
|
||||
state = AuthStatus.offline;
|
||||
} catch (_) {
|
||||
state = AuthStatus.unauthenticated;
|
||||
}
|
||||
@@ -23,6 +30,7 @@ class AuthNotifier extends Notifier<AuthStatus> {
|
||||
Future<void> login(String username, String password) async {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.login(username, password);
|
||||
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||
state = AuthStatus.authenticated;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/briefing_conversation.dart';
|
||||
import '../data/models/message.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
/// Drives the loading indicator in BriefingScreen's reply area.
|
||||
final isBriefingStreamingProvider =
|
||||
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
|
||||
|
||||
class _BoolNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => false;
|
||||
}
|
||||
|
||||
final briefingProvider =
|
||||
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
|
||||
BriefingNotifier.new);
|
||||
|
||||
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
@override
|
||||
Future<BriefingConversation> build() async {
|
||||
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);
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
/// Send a reply to today's briefing conversation.
|
||||
///
|
||||
/// Mirrors MessagesNotifier.sendMessage():
|
||||
/// 1. Optimistic UI update
|
||||
/// 2. POST message to chat endpoint
|
||||
/// 3. SSE stream (best-effort)
|
||||
/// 4. Poll until complete
|
||||
Future<void> sendReply(String content) async {
|
||||
final conv = state.value;
|
||||
if (conv == null) return;
|
||||
final convId = conv.id;
|
||||
final chatApi = ref.read(chatApiProvider);
|
||||
|
||||
final previous = conv.messages;
|
||||
final userMsg = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.user,
|
||||
content: content,
|
||||
);
|
||||
final placeholder = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.assistant,
|
||||
content: '',
|
||||
status: 'generating',
|
||||
);
|
||||
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = true;
|
||||
|
||||
try {
|
||||
await chatApi.sendMessage(convId, content);
|
||||
} catch (e) {
|
||||
state = AsyncData(conv.copyWith(messages: previous));
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||
rethrow;
|
||||
}
|
||||
|
||||
// SSE stream (best-effort)
|
||||
bool streamedContent = false;
|
||||
try {
|
||||
await for (final chunk in chatApi.streamGeneration(convId)) {
|
||||
streamedContent = true;
|
||||
final current = state.value;
|
||||
if (current == null) break;
|
||||
final msgs = current.messages;
|
||||
if (msgs.isEmpty) continue;
|
||||
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
|
||||
state = AsyncData(current.copyWith(
|
||||
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
|
||||
}
|
||||
} catch (_) {
|
||||
// Fall through to polling.
|
||||
}
|
||||
|
||||
// Poll until complete (max 20 attempts, 2s apart)
|
||||
try {
|
||||
for (var attempt = 0; attempt < 20; attempt++) {
|
||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||
final fresh = await ref.read(briefingApiProvider).getMessages(convId);
|
||||
final done = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||
);
|
||||
final hasContent = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||
);
|
||||
final current = state.value;
|
||||
if (current != null && (!streamedContent || done || hasContent)) {
|
||||
state = AsyncData(current.copyWith(messages: fresh));
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Clear the generating placeholder so UI doesn't spin forever.
|
||||
final current = state.value;
|
||||
if (current != null) {
|
||||
final msgs = current.messages;
|
||||
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||
state = AsyncData(current.copyWith(messages: [
|
||||
...msgs.sublist(0, msgs.length - 1),
|
||||
msgs.last.copyWith(status: 'complete'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/calendar_event.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
// ─── CalendarState ────────────────────────────────────────────────────────────
|
||||
|
||||
class CalendarState {
|
||||
final Map<DateTime, List<CalendarEvent>> eventsByDay;
|
||||
final DateTime selectedDay;
|
||||
final DateTime focusedMonth;
|
||||
final DateTimeRange loadedRange;
|
||||
|
||||
const CalendarState({
|
||||
required this.eventsByDay,
|
||||
required this.selectedDay,
|
||||
required this.focusedMonth,
|
||||
required this.loadedRange,
|
||||
});
|
||||
|
||||
CalendarState copyWith({
|
||||
Map<DateTime, List<CalendarEvent>>? eventsByDay,
|
||||
DateTime? selectedDay,
|
||||
DateTime? focusedMonth,
|
||||
DateTimeRange? loadedRange,
|
||||
}) {
|
||||
return CalendarState(
|
||||
eventsByDay: eventsByDay ?? this.eventsByDay,
|
||||
selectedDay: selectedDay ?? this.selectedDay,
|
||||
focusedMonth: focusedMonth ?? this.focusedMonth,
|
||||
loadedRange: loadedRange ?? this.loadedRange,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CalendarNotifier ─────────────────────────────────────────────────────────
|
||||
|
||||
final calendarProvider =
|
||||
AsyncNotifierProvider<CalendarNotifier, CalendarState>(CalendarNotifier.new);
|
||||
|
||||
class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
||||
@override
|
||||
Future<CalendarState> build() async {
|
||||
final now = DateTime.now();
|
||||
final today = dateOnly(now);
|
||||
// Fetch current month ± 1 month as the initial window.
|
||||
final from = DateTime(now.year, now.month - 1, 1);
|
||||
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
|
||||
final events = await ref.watch(eventsRepositoryProvider).getEvents(from, to);
|
||||
return CalendarState(
|
||||
eventsByDay: _groupByDay(events),
|
||||
selectedDay: today,
|
||||
focusedMonth: DateTime(now.year, now.month),
|
||||
loadedRange: DateTimeRange(start: from, end: to),
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-fetch events for the current range without clearing state (no flicker).
|
||||
Future<void> refresh() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final events = await ref.read(eventsRepositoryProvider).getEvents(
|
||||
current.loadedRange.start,
|
||||
current.loadedRange.end,
|
||||
);
|
||||
state = AsyncData(current.copyWith(eventsByDay: _groupByDay(events)));
|
||||
}
|
||||
|
||||
/// Synchronously updates selectedDay and focusedMonth. No API call.
|
||||
void selectDay(DateTime day) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
state = AsyncData(current.copyWith(
|
||||
selectedDay: dateOnly(day),
|
||||
focusedMonth: DateTime(day.year, day.month),
|
||||
));
|
||||
}
|
||||
|
||||
/// Updates focusedMonth. Fetches events for [month] if not already loaded.
|
||||
Future<void> loadMonth(DateTime month) async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final focused = DateTime(month.year, month.month);
|
||||
// Always update focusedMonth so TableCalendar shows the right page.
|
||||
state = AsyncData(current.copyWith(focusedMonth: focused));
|
||||
// Skip fetch if the first day of [month] is within the already-loaded range.
|
||||
final monthStart = DateTime(month.year, month.month, 1);
|
||||
if (!monthStart.isBefore(current.loadedRange.start) &&
|
||||
!monthStart.isAfter(current.loadedRange.end)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final from = DateTime(month.year, month.month, 1);
|
||||
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
|
||||
final events = await ref.read(eventsRepositoryProvider).getEvents(from, to);
|
||||
final s = state.value!;
|
||||
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
|
||||
for (final e in events) {
|
||||
final key = dateOnly(e.startDt);
|
||||
(merged[key] ??= []).add(e);
|
||||
}
|
||||
final newStart =
|
||||
from.isBefore(s.loadedRange.start) ? from : s.loadedRange.start;
|
||||
final newEnd =
|
||||
to.isAfter(s.loadedRange.end) ? to : s.loadedRange.end;
|
||||
state = AsyncData(s.copyWith(
|
||||
eventsByDay: merged,
|
||||
loadedRange: DateTimeRange(start: newStart, end: newEnd),
|
||||
));
|
||||
} catch (_) {
|
||||
// Failures are silent — already-loaded months remain visible.
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts [event] into eventsByDay after a successful createEvent API call.
|
||||
void addEvent(CalendarEvent event) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final key = dateOnly(event.startDt);
|
||||
final updated =
|
||||
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
|
||||
(updated[key] ??= []).add(event);
|
||||
state = AsyncData(current.copyWith(eventsByDay: updated));
|
||||
}
|
||||
|
||||
/// Replaces the old entry for [updated.id] with [updated] after a successful
|
||||
/// updateEvent API call. Scans all buckets to handle date changes.
|
||||
void updateEvent(CalendarEvent updated) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final byDay =
|
||||
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
|
||||
for (final key in byDay.keys) {
|
||||
byDay[key] = byDay[key]!.where((e) => e.id != updated.id).toList();
|
||||
}
|
||||
final newKey = dateOnly(updated.startDt);
|
||||
(byDay[newKey] ??= []).add(updated);
|
||||
state = AsyncData(current.copyWith(eventsByDay: byDay));
|
||||
}
|
||||
|
||||
/// Removes event [id] from [date]'s bucket after a successful deleteEvent
|
||||
/// API call.
|
||||
void removeEvent(int id, DateTime date) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final key = dateOnly(date);
|
||||
final byDay =
|
||||
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
|
||||
byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList();
|
||||
state = AsyncData(current.copyWith(eventsByDay: byDay));
|
||||
}
|
||||
}
|
||||
|
||||
Map<DateTime, List<CalendarEvent>> _groupByDay(List<CalendarEvent> events) {
|
||||
final byDay = <DateTime, List<CalendarEvent>>{};
|
||||
for (final e in events) {
|
||||
final key = dateOnly(e.startDt);
|
||||
(byDay[key] ??= []).add(e);
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/exceptions.dart';
|
||||
import 'api_client_provider.dart';
|
||||
import 'capture_queue_provider.dart';
|
||||
import 'notes_provider.dart';
|
||||
import 'tasks_provider.dart';
|
||||
import 'chat_provider.dart';
|
||||
|
||||
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
|
||||
class CaptureResult {
|
||||
@@ -25,7 +24,6 @@ class _CaptureResultNotifier extends Notifier<CaptureResult?> {
|
||||
}
|
||||
|
||||
/// In-memory sequential work queue for quick captures.
|
||||
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
|
||||
final captureWorkQueueProvider =
|
||||
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||
CaptureWorkQueueNotifier.new,
|
||||
@@ -52,31 +50,24 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
|
||||
// Signal "no result yet" so the same result value can re-trigger watch.
|
||||
ref.read(captureResultProvider.notifier).state = null;
|
||||
try {
|
||||
final api = ref.read(quickCaptureApiProvider);
|
||||
final result = await api.capture(text);
|
||||
// Create a new conversation, add it to the conversations list, then
|
||||
// send the message and kick off generation in the background.
|
||||
final conv =
|
||||
await ref.read(conversationsProvider.notifier).create('');
|
||||
final chatRepo = ref.read(chatRepositoryProvider);
|
||||
await chatRepo.sendMessage(conv.id, text);
|
||||
// Fire-and-forget: drain the SSE stream so the server generates a
|
||||
// response (creating notes/tasks/etc.) without blocking the UI.
|
||||
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
|
||||
|
||||
// Dequeue on success.
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
|
||||
// Invalidate content providers so lists refresh.
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
}
|
||||
|
||||
// Publish result for snackbar.
|
||||
final msg = result.message.isNotEmpty
|
||||
? result.message
|
||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||
ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
|
||||
ref.read(captureResultProvider.notifier).state =
|
||||
const CaptureResult('Sent to Fabled.');
|
||||
} on NetworkException catch (_) {
|
||||
// Persist to offline queue and stop draining — still offline.
|
||||
await ref.read(captureQueueProvider.notifier).enqueue(text);
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
ref.read(captureResultProvider.notifier).state = CaptureResult(
|
||||
ref.read(captureResultProvider.notifier).state = const CaptureResult(
|
||||
"You're offline — capture saved and will retry automatically.",
|
||||
);
|
||||
break;
|
||||
@@ -86,20 +77,13 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
|
||||
CaptureResult(e.message, isError: true);
|
||||
} catch (_) {
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
ref.read(captureResultProvider.notifier).state =
|
||||
CaptureResult('Capture failed. Please try again.', isError: true);
|
||||
ref.read(captureResultProvider.notifier).state = const CaptureResult(
|
||||
'Failed to send. Please try again.',
|
||||
isError: true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'note' => 'Note',
|
||||
'task' => 'Task',
|
||||
'event' => 'Event',
|
||||
'todo' => 'To-do',
|
||||
_ => type,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/conversation.dart';
|
||||
import '../data/models/message.dart';
|
||||
import '../data/repositories/chat_repository.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
// Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops.
|
||||
@@ -18,6 +21,20 @@ class _IsStreamingNotifier extends Notifier<bool> {
|
||||
bool build() => false;
|
||||
}
|
||||
|
||||
// Tracks the current tool status text during generation (empty = no status).
|
||||
final streamingStatusProvider =
|
||||
NotifierProvider.family<_StreamingStatusNotifier, String, int>(
|
||||
(convId) => _StreamingStatusNotifier(convId),
|
||||
);
|
||||
|
||||
class _StreamingStatusNotifier extends Notifier<String> {
|
||||
// ignore: avoid_unused_constructor_parameters
|
||||
_StreamingStatusNotifier(int convId);
|
||||
|
||||
@override
|
||||
String build() => '';
|
||||
}
|
||||
|
||||
final conversationsProvider =
|
||||
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
|
||||
ConversationsNotifier.new);
|
||||
@@ -43,6 +60,12 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
]);
|
||||
}
|
||||
|
||||
/// Re-fetch conversations without clearing the current list (no flicker).
|
||||
Future<void> refresh() async {
|
||||
final fresh = await ref.read(chatRepositoryProvider).getConversations();
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
|
||||
// Called after a message is sent to patch the server-generated title
|
||||
// in-place without triggering a full reload or loading state.
|
||||
void patchConversation(Conversation updated) {
|
||||
@@ -71,6 +94,144 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||
return messages;
|
||||
}
|
||||
|
||||
/// Re-fetch messages without clearing the current list (no flicker).
|
||||
///
|
||||
/// Also unfreezes the UI if streaming state got stuck true — this happens
|
||||
/// when an SSE connection dies silently (mobile network handoff, app
|
||||
/// backgrounded mid-stream, reverse proxy dropping idle sockets) and the
|
||||
/// send loop never observes a close. If the server-side message is already
|
||||
/// done, we clear `isStreamingProvider` so the input unlocks.
|
||||
Future<void> refresh() async {
|
||||
final (_, messages) =
|
||||
await ref.read(chatRepositoryProvider).getMessages(_convId);
|
||||
state = AsyncData(messages);
|
||||
Message? lastAssistant;
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role == MessageRole.assistant) {
|
||||
lastAssistant = messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastAssistant != null && lastAssistant.status != 'generating') {
|
||||
ref.read(isStreamingProvider(_convId).notifier).state = false;
|
||||
ref.read(streamingStatusProvider(_convId).notifier).state = '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach to an already-running generation for this conversation.
|
||||
///
|
||||
/// Used when the chat screen lands on a conversation that was started by
|
||||
/// something other than a direct user message — e.g. the /news discuss
|
||||
/// button, which creates a conversation on the backend and auto-kicks a
|
||||
/// generation before navigating. Without this the stream runs to
|
||||
/// completion invisibly and the screen only shows the final persisted
|
||||
/// message after a manual refresh.
|
||||
///
|
||||
/// Safe to call unconditionally on screen init: no-ops when there is no
|
||||
/// generating assistant message. Mirrors the web chat store's
|
||||
/// reconnectIfGenerating() helper.
|
||||
Future<void> attachToGeneration() async {
|
||||
final convId = _convId;
|
||||
final repo = ref.read(chatRepositoryProvider);
|
||||
|
||||
// Make sure we're looking at fresh server state before deciding whether
|
||||
// to attach. The provider's build() fetches once; if the conversation
|
||||
// was seeded via a POST that happened between build() and this call the
|
||||
// generating placeholder won't be in our in-memory list yet.
|
||||
try {
|
||||
final (_, fresh) = await repo.getMessages(convId);
|
||||
state = AsyncData(fresh);
|
||||
} catch (_) {
|
||||
// If we can't load messages we can't attach either — bail cleanly.
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.read(isStreamingProvider(convId))) return;
|
||||
final msgs = state.value ?? const <Message>[];
|
||||
final hasGeneratingAssistant = msgs.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status == 'generating',
|
||||
);
|
||||
if (!hasGeneratingAssistant) return;
|
||||
|
||||
ref.read(isStreamingProvider(convId).notifier).state = true;
|
||||
|
||||
const stallTimeout = Duration(seconds: 45);
|
||||
bool streamedContent = false;
|
||||
final iter = StreamIterator(repo.streamGeneration(convId));
|
||||
try {
|
||||
while (await iter.moveNext().timeout(stallTimeout)) {
|
||||
final event = iter.current;
|
||||
if (event is ChatTextChunk) {
|
||||
streamedContent = true;
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||
final cur = state.requireValue;
|
||||
if (cur.isEmpty) continue;
|
||||
// Route text chunks into the generating assistant message. The
|
||||
// last message is usually the placeholder, but tool-call fan-in
|
||||
// means we can't rely on that universally.
|
||||
final idx = _findGeneratingAssistantIndex(cur);
|
||||
if (idx < 0) continue;
|
||||
final updated = cur[idx].copyWith(content: cur[idx].content + event.text);
|
||||
state = AsyncData([
|
||||
...cur.sublist(0, idx),
|
||||
updated,
|
||||
...cur.sublist(idx + 1),
|
||||
]);
|
||||
} else if (event is ChatStatusUpdate) {
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = event.status;
|
||||
} else if (event is ChatToolCall) {
|
||||
final cur = state.requireValue;
|
||||
if (cur.isEmpty) continue;
|
||||
final idx = _findGeneratingAssistantIndex(cur);
|
||||
if (idx < 0) continue;
|
||||
final nextCalls = [...?cur[idx].toolCalls, event.toolCall];
|
||||
final updated = cur[idx].copyWith(toolCalls: nextCalls);
|
||||
state = AsyncData([
|
||||
...cur.sublist(0, idx),
|
||||
updated,
|
||||
...cur.sublist(idx + 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
// Stall — fall through to polling.
|
||||
} catch (_) {
|
||||
// Stream failed — fall through to polling.
|
||||
} finally {
|
||||
await iter.cancel();
|
||||
}
|
||||
|
||||
try {
|
||||
for (var attempt = 0; attempt < 20; attempt++) {
|
||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||
final (_, fresh) = await repo.getMessages(convId);
|
||||
final done = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||
);
|
||||
final polledHasContent = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||
);
|
||||
if (!streamedContent || done || polledHasContent) {
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Give up silently — user can pull-to-refresh.
|
||||
} finally {
|
||||
ref.read(isStreamingProvider(convId).notifier).state = false;
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||
}
|
||||
}
|
||||
|
||||
int _findGeneratingAssistantIndex(List<Message> msgs) {
|
||||
for (var i = msgs.length - 1; i >= 0; i--) {
|
||||
final m = msgs[i];
|
||||
if (m.role == MessageRole.assistant && m.status == 'generating') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final convId = _convId;
|
||||
final repo = ref.read(chatRepositoryProvider);
|
||||
@@ -101,17 +262,51 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||
}
|
||||
|
||||
// ── Step 2: Stream the response (best effort — silent on failure). ──
|
||||
//
|
||||
// We use a StreamIterator with a per-event timeout as a stall watchdog.
|
||||
// Mobile networks occasionally drop SSE sockets silently: the TCP
|
||||
// connection is half-closed, Dio never sees the close, and `await for`
|
||||
// hangs forever with `isStreaming=true`, freezing the input. If no
|
||||
// event arrives within the watchdog window we bail out and let the
|
||||
// polling pass below reconcile state from the server.
|
||||
const stallTimeout = Duration(seconds: 45);
|
||||
bool streamedContent = false;
|
||||
final iter = StreamIterator(repo.streamGeneration(convId));
|
||||
try {
|
||||
await for (final chunk in repo.streamGeneration(convId)) {
|
||||
streamedContent = true;
|
||||
final msgs = state.requireValue;
|
||||
if (msgs.isEmpty) continue;
|
||||
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
|
||||
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
|
||||
while (await iter.moveNext().timeout(stallTimeout)) {
|
||||
final event = iter.current;
|
||||
if (event is ChatTextChunk) {
|
||||
streamedContent = true;
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||
final msgs = state.requireValue;
|
||||
if (msgs.isEmpty) continue;
|
||||
final updated =
|
||||
msgs.last.copyWith(content: msgs.last.content + event.text);
|
||||
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
|
||||
} else if (event is ChatStatusUpdate) {
|
||||
ref.read(streamingStatusProvider(convId).notifier).state =
|
||||
event.status;
|
||||
} else if (event is ChatToolCall) {
|
||||
// Append the tool call to the in-flight assistant message so the
|
||||
// chip appears live. The reload pass at the end of this function
|
||||
// will overwrite with the persisted version, which carries the
|
||||
// same shape — no de-dup needed.
|
||||
final msgs = state.requireValue;
|
||||
if (msgs.isEmpty) continue;
|
||||
final last = msgs.last;
|
||||
if (last.role != MessageRole.assistant) continue;
|
||||
final nextCalls = [...?last.toolCalls, event.toolCall];
|
||||
final updated = last.copyWith(toolCalls: nextCalls);
|
||||
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
// Stall watchdog — no SSE event for stallTimeout. Fall through to
|
||||
// polling so the UI eventually unfreezes even if the socket is dead.
|
||||
} catch (_) {
|
||||
// SSE failed — fall through to the polling reload below.
|
||||
} finally {
|
||||
await iter.cancel();
|
||||
}
|
||||
|
||||
// ── Step 3: Poll the API until we have a completed assistant response.
|
||||
@@ -157,6 +352,7 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||
}
|
||||
} finally {
|
||||
ref.read(isStreamingProvider(convId).notifier).state = false;
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/api/chat_api.dart';
|
||||
import '../data/models/journal_day.dart';
|
||||
import '../data/models/message.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
/// Drives the loading indicator in JournalScreen's reply area.
|
||||
final isJournalStreamingProvider =
|
||||
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
|
||||
|
||||
class _BoolNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => false;
|
||||
}
|
||||
|
||||
final journalProvider =
|
||||
AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
|
||||
|
||||
class JournalNotifier extends AsyncNotifier<JournalDay> {
|
||||
@override
|
||||
Future<JournalDay> build() async {
|
||||
return ref.read(journalApiProvider).getToday();
|
||||
}
|
||||
|
||||
/// Silently fetch today's journal 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(journalApiProvider).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.
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-regenerate today's daily prep then reload.
|
||||
Future<void> regeneratePrep() async {
|
||||
await ref.read(journalApiProvider).triggerPrep();
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
/// Re-fetch today's journal and unfreeze a stuck streaming state if the
|
||||
/// server-side message is already complete.
|
||||
///
|
||||
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
|
||||
/// socket dies silently the send loop never observes close and
|
||||
/// [isJournalStreamingProvider] stays stuck true. This is the manual
|
||||
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and
|
||||
/// the lifecycle-resume hook.
|
||||
Future<void> refreshMessages() async {
|
||||
final current = state.value;
|
||||
if (current == null) {
|
||||
ref.invalidateSelf();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final fresh = await ref.read(journalApiProvider).getToday();
|
||||
state = AsyncData(fresh);
|
||||
final messages = fresh.messages;
|
||||
Message? lastAssistant;
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role == MessageRole.assistant) {
|
||||
lastAssistant = messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastAssistant != null && lastAssistant.status != 'generating') {
|
||||
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||
}
|
||||
} catch (_) {
|
||||
// Network hiccup — keep existing state; user can retry.
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a reply to today's journal conversation.
|
||||
///
|
||||
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
|
||||
/// stall-watchdog pattern:
|
||||
/// 1. Optimistic UI update
|
||||
/// 2. POST message to chat endpoint
|
||||
/// 3. SSE stream with per-event timeout (stall watchdog)
|
||||
/// 4. Poll until complete
|
||||
Future<void> sendReply(String content) async {
|
||||
final day = state.value;
|
||||
if (day == null) return;
|
||||
final conv = day.conversation;
|
||||
if (conv == null) return;
|
||||
final convId = conv.id;
|
||||
final chatApi = ref.read(chatApiProvider);
|
||||
|
||||
final previous = day.messages;
|
||||
final userMsg = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.user,
|
||||
content: content,
|
||||
);
|
||||
final placeholder = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.assistant,
|
||||
content: '',
|
||||
status: 'generating',
|
||||
);
|
||||
state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder]));
|
||||
ref.read(isJournalStreamingProvider.notifier).state = true;
|
||||
|
||||
try {
|
||||
await chatApi.sendMessage(convId, content);
|
||||
} catch (e) {
|
||||
state = AsyncData(day.copyWith(messages: previous));
|
||||
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
|
||||
await _pollUntilComplete(convId, streamedContent);
|
||||
}
|
||||
|
||||
/// Consume an SSE stream into the current journal day state.
|
||||
///
|
||||
/// Uses a StreamIterator with a per-event timeout as a stall watchdog —
|
||||
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
|
||||
/// Mobile networks occasionally drop SSE sockets silently. If no event
|
||||
/// arrives within the watchdog window we bail out and let polling
|
||||
/// reconcile state from the server.
|
||||
///
|
||||
/// Returns whether any text content was actually streamed.
|
||||
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
|
||||
const stallTimeout = Duration(seconds: 45);
|
||||
bool streamedContent = false;
|
||||
final iter = StreamIterator(stream);
|
||||
try {
|
||||
while (await iter.moveNext().timeout(stallTimeout)) {
|
||||
final event = iter.current;
|
||||
final current = state.value;
|
||||
if (current == null) break;
|
||||
final msgs = current.messages;
|
||||
if (msgs.isEmpty) continue;
|
||||
if (event is ChatTextChunk) {
|
||||
streamedContent = true;
|
||||
final updated =
|
||||
msgs.last.copyWith(content: msgs.last.content + event.text);
|
||||
state = AsyncData(current.copyWith(
|
||||
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
|
||||
} else if (event is ChatToolCall) {
|
||||
final last = msgs.last;
|
||||
if (last.role != MessageRole.assistant) continue;
|
||||
final nextCalls = [...?last.toolCalls, event.toolCall];
|
||||
final updated = last.copyWith(toolCalls: nextCalls);
|
||||
state = AsyncData(current.copyWith(
|
||||
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
// Stall watchdog — no SSE event for stallTimeout. Fall through to
|
||||
// polling so the UI eventually unfreezes even if the socket is dead.
|
||||
} catch (_) {
|
||||
// SSE failed — fall through to polling.
|
||||
} finally {
|
||||
await iter.cancel();
|
||||
}
|
||||
return streamedContent;
|
||||
}
|
||||
|
||||
/// Poll today's journal until the last assistant row is complete. Always
|
||||
/// clears [isJournalStreamingProvider] at the end so the input can't stay
|
||||
/// locked.
|
||||
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
|
||||
final journalApi = ref.read(journalApiProvider);
|
||||
try {
|
||||
for (var attempt = 0; attempt < 20; attempt++) {
|
||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||
final fresh = await journalApi.getToday();
|
||||
final freshMsgs = fresh.messages;
|
||||
final done = freshMsgs.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||
);
|
||||
final hasContent = freshMsgs.any(
|
||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||
);
|
||||
final current = state.value;
|
||||
if (current != null && (!streamedContent || done || hasContent)) {
|
||||
state = AsyncData(current.copyWith(messages: freshMsgs));
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} catch (_) {
|
||||
final current = state.value;
|
||||
if (current != null) {
|
||||
final msgs = current.messages;
|
||||
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||
state = AsyncData(current.copyWith(messages: [
|
||||
...msgs.sublist(0, msgs.length - 1),
|
||||
msgs.last.copyWith(status: 'complete'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/knowledge_item.dart';
|
||||
import '../data/repositories/knowledge_repository.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
export '../data/models/knowledge_item.dart';
|
||||
|
||||
/// Immutable state for the Knowledge screen's two-tier paginated feed.
|
||||
class KnowledgeState {
|
||||
final List<int> ids;
|
||||
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 {
|
||||
// Keep current items visible during re-fetch (no flicker).
|
||||
try {
|
||||
if (state.noteType == 'task') {
|
||||
final tasks = await ref.read(tasksApiProvider).getAll();
|
||||
final items = {
|
||||
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
|
||||
};
|
||||
state = state.copyWith(
|
||||
ids: tasks.map((t) => t.id).toList(),
|
||||
items: items,
|
||||
totalIds: tasks.length,
|
||||
hasMore: false,
|
||||
);
|
||||
await _loadCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
final (ids, total) = await _repo.fetchIds(
|
||||
noteType: state.noteType,
|
||||
tags: state.activeTags,
|
||||
q: state.searchQuery,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
);
|
||||
// Hydrate the new IDs
|
||||
final batch = await _repo.fetchBatch(ids);
|
||||
final freshItems = {for (final item in batch) item.id: item};
|
||||
state = state.copyWith(
|
||||
ids: ids,
|
||||
items: freshItems,
|
||||
totalIds: total,
|
||||
hasMore: ids.length < total,
|
||||
);
|
||||
await Future.wait([_loadCounts(), _loadTags()]);
|
||||
} catch (_) {
|
||||
// Silent — stale data is better than an error on refresh
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scroll-triggered loaders ─────────────────────────────────────────────
|
||||
|
||||
/// 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 {
|
||||
if (state.noteType == 'task') {
|
||||
// Tasks live under /api/tasks — fetch all and convert directly.
|
||||
final tasks = await ref.read(tasksApiProvider).getAll();
|
||||
final items = {
|
||||
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
|
||||
};
|
||||
state = state.copyWith(
|
||||
ids: tasks.map((t) => t.id).toList(),
|
||||
items: items,
|
||||
totalIds: tasks.length,
|
||||
isLoadingIds: false,
|
||||
hasMore: false,
|
||||
);
|
||||
await _loadCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
final (ids, total) = await _repo.fetchIds(
|
||||
noteType: state.noteType,
|
||||
tags: state.activeTags,
|
||||
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,
|
||||
);
|
||||
// Hydrate the newly fetched IDs immediately — the user is
|
||||
// already at the scroll bottom so _onScroll won't re-fire.
|
||||
await hydrateNext();
|
||||
} 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,15 @@ 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<void> refresh() async {
|
||||
final fresh = await ref.read(projectsRepositoryProvider)
|
||||
.getAll(sort: 'updated_at', order: 'desc');
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
|
||||
Future<Project> create({
|
||||
|
||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
const _kServerUrl = 'server_url';
|
||||
const _kThemeMode = 'theme_mode';
|
||||
const _kForgejoRepoUrl = 'forgejo_repo_url';
|
||||
const _kHasEverLoggedIn = 'has_ever_logged_in';
|
||||
|
||||
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
||||
throw UnimplementedError('Override in ProviderScope');
|
||||
@@ -82,3 +85,47 @@ class ServerUrlNotifier extends Notifier<String?> {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks whether this install has ever completed a successful login.
|
||||
/// Used so offline users who've logged in before land on the briefing (with a
|
||||
/// banner) instead of being dumped onto the login screen as if freshly installed.
|
||||
final hasEverLoggedInProvider =
|
||||
NotifierProvider<HasEverLoggedInNotifier, bool>(HasEverLoggedInNotifier.new);
|
||||
|
||||
class HasEverLoggedInNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return prefs.getBool(_kHasEverLoggedIn) ?? false;
|
||||
}
|
||||
|
||||
Future<void> markLoggedIn() async {
|
||||
if (state) return;
|
||||
await ref.read(sharedPreferencesProvider).setBool(_kHasEverLoggedIn, true);
|
||||
state = true;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await ref.read(sharedPreferencesProvider).remove(_kHasEverLoggedIn);
|
||||
state = false;
|
||||
}
|
||||
}
|
||||
|
||||
final serverSettingsProvider =
|
||||
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
|
||||
ServerSettingsNotifier.new);
|
||||
|
||||
class ServerSettingsNotifier extends AsyncNotifier<Map<String, dynamic>> {
|
||||
@override
|
||||
Future<Map<String, dynamic>> build() async {
|
||||
try {
|
||||
return await ref.read(settingsApiProvider).getAll();
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = AsyncData(await ref.read(settingsApiProvider).getAll());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
return ref.watch(tasksRepositoryProvider).getAll();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final fresh = await ref.read(tasksRepositoryProvider).getAll();
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
|
||||
Future<Task> create({
|
||||
required String title,
|
||||
String? description,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
@@ -5,7 +7,14 @@ 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 }
|
||||
enum UpdateStatus {
|
||||
idle,
|
||||
checking,
|
||||
downloading,
|
||||
readyToInstall,
|
||||
upToDate,
|
||||
error,
|
||||
}
|
||||
|
||||
class UpdateState {
|
||||
final UpdateStatus status;
|
||||
@@ -14,6 +23,7 @@ class UpdateState {
|
||||
final String? downloadUrl;
|
||||
final double downloadProgress;
|
||||
final String? errorMessage;
|
||||
final String? apkPath;
|
||||
|
||||
const UpdateState({
|
||||
this.status = UpdateStatus.idle,
|
||||
@@ -22,6 +32,7 @@ class UpdateState {
|
||||
this.downloadUrl,
|
||||
this.downloadProgress = 0.0,
|
||||
this.errorMessage,
|
||||
this.apkPath,
|
||||
});
|
||||
|
||||
UpdateState copyWith({
|
||||
@@ -31,6 +42,7 @@ class UpdateState {
|
||||
String? downloadUrl,
|
||||
double? downloadProgress,
|
||||
String? errorMessage,
|
||||
String? apkPath,
|
||||
}) =>
|
||||
UpdateState(
|
||||
status: status ?? this.status,
|
||||
@@ -39,6 +51,7 @@ class UpdateState {
|
||||
downloadUrl: downloadUrl ?? this.downloadUrl,
|
||||
downloadProgress: downloadProgress ?? this.downloadProgress,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
apkPath: apkPath ?? this.apkPath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
@override
|
||||
UpdateState build() => const UpdateState();
|
||||
|
||||
/// [repoUrl] is the Forgejo repo page URL, e.g.
|
||||
/// "https://git.example.com/user/fabled_app"
|
||||
Future<void> check(String repoUrl) async {
|
||||
state = state.copyWith(status: UpdateStatus.checking);
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
|
||||
final currentVersion =
|
||||
'${packageInfo.version}.${packageInfo.buildNumber}';
|
||||
|
||||
// Parse repo URL → Forgejo API endpoint
|
||||
final uri = Uri.parse(repoUrl);
|
||||
final parts =
|
||||
uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
|
||||
final apiUrl =
|
||||
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest';
|
||||
@@ -70,20 +78,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
tagName.startsWith('v') ? tagName.substring(1) : tagName;
|
||||
|
||||
if (_isNewer(latestVersion, currentVersion)) {
|
||||
final assets =
|
||||
(response.data['assets'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final assets = (response.data['assets'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
final apk = assets.firstWhere(
|
||||
(a) => (a['name'] as String? ?? '').endsWith('.apk'),
|
||||
orElse: () => {},
|
||||
);
|
||||
if (apk.isNotEmpty) {
|
||||
final downloadUrl = apk['browser_download_url'] as String?;
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.available,
|
||||
currentVersion: currentVersion,
|
||||
latestVersion: latestVersion,
|
||||
downloadUrl: apk['browser_download_url'] as String?,
|
||||
downloadUrl: downloadUrl,
|
||||
);
|
||||
await _downloadInBackground();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> downloadAndInstall() async {
|
||||
Future<void> _downloadInBackground() async {
|
||||
if (state.downloadUrl == null) return;
|
||||
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
||||
|
||||
try {
|
||||
final dir = await _apkDir();
|
||||
await _cleanupApks(dir);
|
||||
|
||||
final path = '${dir.path}/fabled_${state.latestVersion}.apk';
|
||||
|
||||
await Dio().download(
|
||||
state.downloadUrl!,
|
||||
path,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (total > 0) {
|
||||
state = state.copyWith(downloadProgress: received / total);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final file = File(path);
|
||||
if (!await file.exists() || await file.length() == 0) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage: 'Download failed — file is empty',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.readyToInstall,
|
||||
apkPath: path,
|
||||
downloadProgress: 1.0,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> install() async {
|
||||
final path = state.apkPath;
|
||||
if (path == 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();
|
||||
@@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
||||
|
||||
try {
|
||||
final dir = await getExternalStorageDirectory() ??
|
||||
await getTemporaryDirectory();
|
||||
final path = '${dir.path}/fabled_update.apk';
|
||||
|
||||
await Dio().download(
|
||||
state.downloadUrl!,
|
||||
path,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (total > 0) {
|
||||
state = state.copyWith(downloadProgress: received / total);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final result = await OpenFile.open(
|
||||
path,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
|
||||
if (result.type == ResultType.done) {
|
||||
// Installer launched — reset to idle so the dialog closes naturally.
|
||||
state = const UpdateState();
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
@@ -158,8 +190,30 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove any previously cached APKs.
|
||||
Future<void> cleanup() async {
|
||||
final dir = await _apkDir();
|
||||
await _cleanupApks(dir);
|
||||
}
|
||||
|
||||
void dismiss() => state = const UpdateState();
|
||||
|
||||
Future<Directory> _apkDir() async {
|
||||
return await getExternalStorageDirectory() ??
|
||||
await getTemporaryDirectory();
|
||||
}
|
||||
|
||||
Future<void> _cleanupApks(Directory dir) async {
|
||||
try {
|
||||
final entries = dir.listSync();
|
||||
for (final entry in entries) {
|
||||
if (entry is File && entry.path.endsWith('.apk')) {
|
||||
await entry.delete();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
bool _isNewer(String latest, String current) {
|
||||
try {
|
||||
final l = latest.split('.').map(int.parse).toList();
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
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:vad/vad.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();
|
||||
}
|
||||
|
||||
/// Encode float PCM samples (-1..1) as a 16-bit mono WAV at 16 kHz.
|
||||
Uint8List encodeWav(List<double> samples, {int sampleRate = 16000}) {
|
||||
final numSamples = samples.length;
|
||||
final dataSize = numSamples * 2;
|
||||
final fileSize = 44 + dataSize;
|
||||
final buf = ByteData(fileSize);
|
||||
|
||||
// RIFF header
|
||||
buf.setUint8(0, 0x52); // R
|
||||
buf.setUint8(1, 0x49); // I
|
||||
buf.setUint8(2, 0x46); // F
|
||||
buf.setUint8(3, 0x46); // F
|
||||
buf.setUint32(4, fileSize - 8, Endian.little);
|
||||
buf.setUint8(8, 0x57); // W
|
||||
buf.setUint8(9, 0x41); // A
|
||||
buf.setUint8(10, 0x56); // V
|
||||
buf.setUint8(11, 0x45); // E
|
||||
|
||||
// fmt chunk
|
||||
buf.setUint8(12, 0x66); // f
|
||||
buf.setUint8(13, 0x6D); // m
|
||||
buf.setUint8(14, 0x74); // t
|
||||
buf.setUint8(15, 0x20); // (space)
|
||||
buf.setUint32(16, 16, Endian.little); // chunk size
|
||||
buf.setUint16(20, 1, Endian.little); // PCM format
|
||||
buf.setUint16(22, 1, Endian.little); // mono
|
||||
buf.setUint32(24, sampleRate, Endian.little);
|
||||
buf.setUint32(28, sampleRate * 2, Endian.little); // byte rate
|
||||
buf.setUint16(32, 2, Endian.little); // block align
|
||||
buf.setUint16(34, 16, Endian.little); // bits per sample
|
||||
|
||||
// data chunk
|
||||
buf.setUint8(36, 0x64); // d
|
||||
buf.setUint8(37, 0x61); // a
|
||||
buf.setUint8(38, 0x74); // t
|
||||
buf.setUint8(39, 0x61); // a
|
||||
buf.setUint32(40, dataSize, Endian.little);
|
||||
|
||||
for (var i = 0; i < numSamples; i++) {
|
||||
final clamped = samples[i].clamp(-1.0, 1.0);
|
||||
final int16 = (clamped * 32767).round().clamp(-32768, 32767);
|
||||
buf.setInt16(44 + i * 2, int16, Endian.little);
|
||||
}
|
||||
|
||||
return buf.buffer.asUint8List();
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum VoiceMode { idle, recording, transcribing, playing }
|
||||
|
||||
class VoiceState {
|
||||
final VoiceMode mode;
|
||||
final bool voiceModeActive;
|
||||
final bool available;
|
||||
/// Normalized mic amplitude 0.0–1.0 while recording.
|
||||
final double amplitude;
|
||||
|
||||
const VoiceState({
|
||||
this.mode = VoiceMode.idle,
|
||||
this.voiceModeActive = false,
|
||||
this.available = true,
|
||||
this.amplitude = 0.0,
|
||||
});
|
||||
|
||||
VoiceState copyWith({
|
||||
VoiceMode? mode,
|
||||
bool? voiceModeActive,
|
||||
bool? available,
|
||||
double? amplitude,
|
||||
}) =>
|
||||
VoiceState(
|
||||
mode: mode ?? this.mode,
|
||||
voiceModeActive: voiceModeActive ?? this.voiceModeActive,
|
||||
available: available ?? this.available,
|
||||
amplitude: amplitude ?? this.amplitude,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Provider ──────────────────────────────────────────────────────────────────
|
||||
|
||||
final voiceProvider =
|
||||
NotifierProvider.autoDispose<VoiceNotifier, VoiceState>(VoiceNotifier.new);
|
||||
|
||||
// ── Notifier ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class VoiceNotifier extends Notifier<VoiceState> {
|
||||
// Audio playback
|
||||
AudioPlayer? _player;
|
||||
|
||||
// VAD — sole owner of the microphone
|
||||
VadHandler? _vadHandler;
|
||||
StreamSubscription<void>? _vadSpeechStartSub;
|
||||
StreamSubscription<List<double>>? _vadSpeechEndSub;
|
||||
StreamSubscription<({double isSpeech, double notSpeech, List<double> frame})>?
|
||||
_vadFrameSub;
|
||||
StreamSubscription<String>? _vadErrorSub;
|
||||
bool _speechDetected = false;
|
||||
int _speechStartMs = 0;
|
||||
static const _vadGraceMs = 1500;
|
||||
bool _disposed = false;
|
||||
|
||||
// Voice mode callbacks
|
||||
Future<void> Function(String transcript)? _onTranscript;
|
||||
void Function(String message)? _onError;
|
||||
bool _enableTts = false;
|
||||
|
||||
// Streaming TTS state
|
||||
String _sentenceBuffer = '';
|
||||
int _lastSeenLength = 0;
|
||||
bool _streamComplete = false;
|
||||
|
||||
// Whisper context hint
|
||||
String _lastAssistantContent = '';
|
||||
|
||||
// Empty transcript counter
|
||||
int _emptyTranscriptCount = 0;
|
||||
|
||||
// TTS playback queue
|
||||
final _ttsQueue = Queue<Uint8List>();
|
||||
bool _ttsPlaying = false;
|
||||
int _ttsCounter = 0;
|
||||
Directory? _tempDir;
|
||||
|
||||
@override
|
||||
VoiceState build() {
|
||||
_disposed = false;
|
||||
_player = AudioPlayer();
|
||||
ref.onDispose(() {
|
||||
_disposed = true;
|
||||
_cancelSubscriptions();
|
||||
_vadHandler?.dispose();
|
||||
_vadHandler = null;
|
||||
_player?.dispose();
|
||||
_player = null;
|
||||
});
|
||||
return const VoiceState();
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> enterVoiceMode({
|
||||
required Future<void> Function(String transcript) onTranscript,
|
||||
bool enableTts = false,
|
||||
required void Function(String message) onError,
|
||||
}) async {
|
||||
if (state.voiceModeActive) {
|
||||
if (state.mode == VoiceMode.recording && !_speechDetected) {
|
||||
onError('No speech detected');
|
||||
}
|
||||
exitVoiceMode();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final status = await ref.read(voiceRepositoryProvider).checkStatus();
|
||||
if (!status.enabled || !status.stt) {
|
||||
onError('Speech-to-text not available on this server');
|
||||
return;
|
||||
}
|
||||
if (!status.tts) enableTts = false;
|
||||
} catch (_) {
|
||||
onError('Could not reach voice service');
|
||||
return;
|
||||
}
|
||||
|
||||
var permStatus = await Permission.microphone.request();
|
||||
if (permStatus == PermissionStatus.permanentlyDenied) {
|
||||
onError('Microphone blocked — opening settings');
|
||||
final opened = await openAppSettings();
|
||||
if (!opened) return;
|
||||
permStatus = await Permission.microphone.status;
|
||||
}
|
||||
if (!permStatus.isGranted) {
|
||||
onError('Microphone permission required');
|
||||
return;
|
||||
}
|
||||
|
||||
_onTranscript = onTranscript;
|
||||
_onError = onError;
|
||||
_enableTts = enableTts;
|
||||
_emptyTranscriptCount = 0;
|
||||
_tempDir = await getTemporaryDirectory();
|
||||
|
||||
state = state.copyWith(voiceModeActive: true, available: true);
|
||||
await _startListening();
|
||||
}
|
||||
|
||||
void exitVoiceMode() {
|
||||
_cleanup();
|
||||
if (!_disposed) state = const VoiceState();
|
||||
}
|
||||
|
||||
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) {
|
||||
_lastAssistantContent = fullContent;
|
||||
_streamComplete = true;
|
||||
_checkRestartListening();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
void _cleanup() {
|
||||
_cancelSubscriptions();
|
||||
final handler = _vadHandler;
|
||||
_vadHandler = null;
|
||||
handler?.dispose();
|
||||
_player?.stop();
|
||||
_ttsQueue.clear();
|
||||
_ttsPlaying = false;
|
||||
_sentenceBuffer = '';
|
||||
_lastSeenLength = 0;
|
||||
_streamComplete = false;
|
||||
_speechDetected = false;
|
||||
_onTranscript = null;
|
||||
_onError = null;
|
||||
}
|
||||
|
||||
void _cancelSubscriptions() {
|
||||
_vadSpeechStartSub?.cancel();
|
||||
_vadSpeechStartSub = null;
|
||||
_vadSpeechEndSub?.cancel();
|
||||
_vadSpeechEndSub = null;
|
||||
_vadFrameSub?.cancel();
|
||||
_vadFrameSub = null;
|
||||
_vadErrorSub?.cancel();
|
||||
_vadErrorSub = null;
|
||||
}
|
||||
|
||||
// ── Internal recording ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _startListening() async {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
|
||||
_speechDetected = false;
|
||||
_speechStartMs = 0;
|
||||
|
||||
try {
|
||||
await _stopVad();
|
||||
_vadHandler = VadHandler.create();
|
||||
|
||||
_vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
if (!_speechDetected) {
|
||||
_speechDetected = true;
|
||||
_speechStartMs = DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
});
|
||||
|
||||
_vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((audioSamples) {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final sinceStart = _speechStartMs > 0 ? now - _speechStartMs : 0;
|
||||
if (_speechDetected && sinceStart >= _vadGraceMs) {
|
||||
_stopVad();
|
||||
_handleSpeechEnd(audioSamples);
|
||||
}
|
||||
});
|
||||
|
||||
_vadFrameSub = _vadHandler!.onFrameProcessed.listen((event) {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
final frame = event.frame;
|
||||
if (frame.isEmpty) return;
|
||||
double sumSq = 0;
|
||||
for (final s in frame) {
|
||||
sumSq += s * s;
|
||||
}
|
||||
final rms = sqrt(sumSq / frame.length);
|
||||
final norm = (rms * 4.0).clamp(0.0, 1.0);
|
||||
if ((norm - state.amplitude).abs() > 0.02) {
|
||||
state = state.copyWith(amplitude: norm);
|
||||
}
|
||||
});
|
||||
|
||||
_vadErrorSub = _vadHandler!.onError.listen((msg) {
|
||||
if (_disposed) return;
|
||||
_onError?.call('VAD error: $msg');
|
||||
});
|
||||
|
||||
await _vadHandler!.startListening(model: 'v5');
|
||||
|
||||
// Only show recording UI after the mic is actually open.
|
||||
if (!_disposed && state.voiceModeActive) {
|
||||
state = state.copyWith(mode: VoiceMode.recording, amplitude: 0.0);
|
||||
}
|
||||
} catch (e) {
|
||||
_onError?.call('Microphone error: $e');
|
||||
_cleanup();
|
||||
if (!_disposed) state = const VoiceState();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopVad() async {
|
||||
_cancelSubscriptions();
|
||||
if (_vadHandler != null) {
|
||||
final handler = _vadHandler!;
|
||||
_vadHandler = null;
|
||||
await handler.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSpeechEnd(List<double> audioSamples) async {
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
state = state.copyWith(mode: VoiceMode.transcribing);
|
||||
|
||||
try {
|
||||
final wavBytes = encodeWav(audioSamples);
|
||||
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
|
||||
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
|
||||
wavBytes,
|
||||
context:
|
||||
_lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
|
||||
);
|
||||
|
||||
if (_disposed || !state.voiceModeActive) return;
|
||||
|
||||
if (transcript.isEmpty) {
|
||||
_emptyTranscriptCount++;
|
||||
if (_emptyTranscriptCount >= 3) {
|
||||
_onError?.call('No speech detected — tap the mic to try again');
|
||||
_cleanup();
|
||||
if (!_disposed) state = const VoiceState();
|
||||
|
||||
return;
|
||||
}
|
||||
await _startListening();
|
||||
return;
|
||||
}
|
||||
_emptyTranscriptCount = 0;
|
||||
|
||||
_sentenceBuffer = '';
|
||||
_lastSeenLength = 0;
|
||||
_streamComplete = false;
|
||||
|
||||
if (_enableTts && !_disposed) {
|
||||
state = state.copyWith(mode: VoiceMode.playing);
|
||||
}
|
||||
|
||||
await _onTranscript?.call(transcript);
|
||||
|
||||
// In STT-only mode (no TTS), return to idle after transcript is sent.
|
||||
// The user taps the mic again to record another message.
|
||||
if (!_enableTts && !_disposed && state.voiceModeActive) {
|
||||
state = state.copyWith(mode: VoiceMode.idle);
|
||||
}
|
||||
} catch (e) {
|
||||
_onError?.call('Voice error: transcription failed');
|
||||
_cleanup();
|
||||
if (!_disposed) state = const VoiceState();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 (_disposed || !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 (_disposed) return;
|
||||
if (_streamComplete &&
|
||||
_ttsQueue.isEmpty &&
|
||||
!_ttsPlaying &&
|
||||
state.voiceModeActive) {
|
||||
_streamComplete = false;
|
||||
_lastSeenLength = 0;
|
||||
_sentenceBuffer = '';
|
||||
_startListening();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -72,7 +73,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
if (mounted) context.go(Routes.briefing);
|
||||
if (mounted) context.go(Routes.journal);
|
||||
} on AuthException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} on AppException catch (e) {
|
||||
@@ -90,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
cookieJar: ref.read(cookieJarProvider),
|
||||
onSuccess: () async {
|
||||
await ref.read(authProvider.notifier).verify();
|
||||
if (mounted) context.go(Routes.briefing);
|
||||
if (mounted) context.go(Routes.journal);
|
||||
},
|
||||
),
|
||||
));
|
||||
@@ -119,7 +120,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
const SizedBox(height: 32),
|
||||
if (_oauthEnabled) ...[
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.login),
|
||||
icon: const Icon(LucideIcons.logIn),
|
||||
label: const Text('Sign in with SSO'),
|
||||
onPressed: _openOAuth,
|
||||
),
|
||||
@@ -155,8 +156,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscure
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off),
|
||||
? LucideIcons.eye
|
||||
: LucideIcons.eyeOff),
|
||||
onPressed: () =>
|
||||
setState(() => _obscure = !_obscure),
|
||||
),
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/models/briefing_conversation.dart';
|
||||
import '../../data/models/message.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
|
||||
class BriefingHistoryScreen extends ConsumerWidget {
|
||||
const BriefingHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final historyAsync = ref.watch(_briefingHistoryProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Past Briefings')),
|
||||
body: historyAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) =>
|
||||
const Center(child: Text('Could not load briefing history.')),
|
||||
data: (convs) {
|
||||
if (convs.isEmpty) {
|
||||
return const Center(child: Text('No past briefings.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: convs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final conv = convs[i];
|
||||
final label = conv.briefingDate ?? conv.title;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.wb_sunny_outlined),
|
||||
title: Text(label),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _BriefingDetailScreen(conv: conv),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily loads and displays all messages for a past briefing.
|
||||
class _BriefingDetailScreen extends ConsumerWidget {
|
||||
final BriefingConversation conv;
|
||||
const _BriefingDetailScreen({required this.conv});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messagesAsync = ref.watch(_briefingMessagesProvider(conv.id));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(conv.briefingDate ?? conv.title)),
|
||||
body: messagesAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const Center(child: Text('Could not load messages.')),
|
||||
data: (messages) {
|
||||
if (messages.isEmpty) {
|
||||
return const Center(child: Text('No messages.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (_, i) => ChatMessageBubble(message: messages[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private providers (scoped to this file) ──────────────────────────────────
|
||||
|
||||
final _briefingHistoryProvider =
|
||||
FutureProvider<List<BriefingConversation>>((ref) async {
|
||||
return ref.watch(briefingApiProvider).getHistory();
|
||||
});
|
||||
|
||||
final _briefingMessagesProvider =
|
||||
FutureProvider.family<List<Message>, int>((ref, convId) async {
|
||||
return ref.watch(briefingApiProvider).getMessages(convId);
|
||||
});
|
||||
@@ -1,461 +0,0 @@
|
||||
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 '../../providers/api_client_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
import '../../widgets/weather_card.dart';
|
||||
import 'briefing_history_screen.dart';
|
||||
|
||||
class BriefingScreen extends ConsumerStatefulWidget {
|
||||
const BriefingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
||||
}
|
||||
|
||||
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();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendReply() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
try {
|
||||
await ref.read(briefingProvider.notifier).sendReply(text);
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to send reply.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
await ref.read(briefingProvider.notifier).refresh('compilation');
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not generate briefing.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final briefingAsync = ref.watch(briefingProvider);
|
||||
final isStreaming = ref.watch(isBriefingStreamingProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
ref.listen(briefingProvider, (_, _) => _scrollToBottom());
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Briefing', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_refreshing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_outlined),
|
||||
tooltip: 'Generate briefing',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'history') {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const BriefingHistoryScreen(),
|
||||
));
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: 'history',
|
||||
child: Text('View past briefings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: briefingAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Could not load today's briefing."),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(briefingProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (conv) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Progress bar while streaming
|
||||
if (isStreaming)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
|
||||
// Reply bar
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Reply to your briefing…',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
),
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.newline,
|
||||
enabled: !isStreaming,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_GradientSendButton(
|
||||
onPressed: isStreaming ? null : _sendReply,
|
||||
isStreaming: isStreaming,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _todayLabel() {
|
||||
final now = DateTime.now();
|
||||
const days = [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'
|
||||
];
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 reactions
|
||||
final rssItemIds = isAssistant && meta != null
|
||||
? (meta['rss_item_ids'] as List<dynamic>?)?.cast<int>() ?? []
|
||||
: <int>[];
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasWeatherKey) WeatherCard(weather: weatherData),
|
||||
ChatMessageBubble(message: message),
|
||||
if (rssItemIds.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: rssItemIds.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final itemId = entry.value;
|
||||
final current = reactions[itemId];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Story ${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ReactionButton(
|
||||
emoji: '👍',
|
||||
active: current == 'up',
|
||||
onTap: () => onReaction(itemId, 'up'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_ReactionButton(
|
||||
emoji: '👎',
|
||||
active: current == 'down',
|
||||
onTap: () => onReaction(itemId, 'down'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientSendButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final bool isStreaming;
|
||||
|
||||
const _GradientSendButton({
|
||||
required this.onPressed,
|
||||
required this.isStreaming,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final disabled = onPressed == null;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: disabled
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||
),
|
||||
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: isStreaming
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send,
|
||||
color: disabled
|
||||
? scheme.onSurface.withValues(alpha: 0.38)
|
||||
: Colors.white,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
|
||||
import '../../data/models/calendar_event.dart';
|
||||
import '../../providers/calendar_provider.dart';
|
||||
import 'event_form_sheet.dart';
|
||||
|
||||
class CalendarScreen extends ConsumerWidget {
|
||||
const CalendarScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final calAsync = ref.watch(calendarProvider);
|
||||
final notifier = ref.read(calendarProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Calendar')),
|
||||
body: calAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Could not load calendar.'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(calendarProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (cal) => Column(
|
||||
children: [
|
||||
TableCalendar<CalendarEvent>(
|
||||
firstDay: DateTime(2020),
|
||||
lastDay: DateTime(2030),
|
||||
focusedDay: cal.focusedMonth,
|
||||
selectedDayPredicate: (day) => isSameDay(day, cal.selectedDay),
|
||||
eventLoader: (day) =>
|
||||
cal.eventsByDay[dateOnly(day)] ?? [],
|
||||
calendarFormat: CalendarFormat.month,
|
||||
headerStyle: const HeaderStyle(
|
||||
formatButtonVisible: false,
|
||||
titleCentered: true,
|
||||
),
|
||||
calendarStyle: CalendarStyle(
|
||||
selectedDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
todayDecoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
markerDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
onDaySelected: (selected, _) => notifier.selectDay(selected),
|
||||
onPageChanged: (focused) => notifier.loadMonth(focused),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(calendarProvider.notifier).refresh(),
|
||||
child: _AgendaList(
|
||||
events:
|
||||
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
|
||||
notifier: notifier,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: calAsync.hasValue
|
||||
? FloatingActionButton(
|
||||
onPressed: () => showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => EventFormSheet(
|
||||
event: null,
|
||||
initialDate: calAsync.value!.selectedDay,
|
||||
notifier: notifier,
|
||||
),
|
||||
),
|
||||
child: const Icon(LucideIcons.plus),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agenda list ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _AgendaList extends StatelessWidget {
|
||||
final List<CalendarEvent> events;
|
||||
final CalendarNotifier notifier;
|
||||
|
||||
const _AgendaList({required this.events, required this.notifier});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (events.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(child: Text('No events')),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (_, i) =>
|
||||
_EventTile(event: events[i], notifier: notifier),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event tile ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EventTile extends StatelessWidget {
|
||||
final CalendarEvent event;
|
||||
final CalendarNotifier notifier;
|
||||
|
||||
const _EventTile({required this.event, required this.notifier});
|
||||
|
||||
Color _dotColor(BuildContext context) {
|
||||
if (event.color.isEmpty) return Theme.of(context).colorScheme.primary;
|
||||
try {
|
||||
return Color(int.parse(event.color.replaceFirst('#', '0xFF')));
|
||||
} catch (_) {
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
|
||||
String _timeLabel() {
|
||||
if (event.allDay) return 'All day';
|
||||
final h = event.startDt.hour.toString().padLeft(2, '0');
|
||||
final m = event.startDt.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 6,
|
||||
backgroundColor: _dotColor(context),
|
||||
),
|
||||
title: Text(event.title),
|
||||
subtitle: Text(_timeLabel()),
|
||||
onTap: () => showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => EventFormSheet(
|
||||
event: event,
|
||||
initialDate: null,
|
||||
notifier: notifier,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme.dart';
|
||||
import '../../data/models/calendar_event.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/calendar_provider.dart';
|
||||
|
||||
class EventFormSheet extends ConsumerStatefulWidget {
|
||||
/// null = create mode; non-null = edit mode.
|
||||
final CalendarEvent? event;
|
||||
|
||||
/// Pre-fills start date in create mode. Ignored in edit mode.
|
||||
final DateTime? initialDate;
|
||||
|
||||
final CalendarNotifier notifier;
|
||||
|
||||
const EventFormSheet({
|
||||
super.key,
|
||||
required this.event,
|
||||
required this.initialDate,
|
||||
required this.notifier,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<EventFormSheet> createState() => _EventFormSheetState();
|
||||
}
|
||||
|
||||
class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
late final TextEditingController _titleCtrl;
|
||||
late final TextEditingController _descCtrl;
|
||||
late final TextEditingController _locationCtrl;
|
||||
|
||||
late DateTime _startDt;
|
||||
DateTime? _endDt;
|
||||
late bool _allDay;
|
||||
String? _recurrence; // null = None; one of the FREQ= strings otherwise
|
||||
String _color = '';
|
||||
bool _saving = false;
|
||||
|
||||
bool get _isCreate => widget.event == null;
|
||||
|
||||
/// Maps RRULE string (or null) to display label for the dropdown.
|
||||
static const Map<String?, String> _knownRrules = {
|
||||
null: 'None',
|
||||
'FREQ=DAILY': 'Daily',
|
||||
'FREQ=WEEKLY': 'Weekly',
|
||||
'FREQ=MONTHLY': 'Monthly',
|
||||
'FREQ=YEARLY': 'Yearly',
|
||||
};
|
||||
|
||||
/// True when editing an event whose RRULE is not one of the 5 known patterns.
|
||||
bool get _isCustomRrule =>
|
||||
widget.event?.recurrence != null &&
|
||||
!_knownRrules.containsKey(widget.event!.recurrence);
|
||||
|
||||
static const List<String> _presetColors = [
|
||||
'#EF4444',
|
||||
'#F59E0B',
|
||||
'#10B981',
|
||||
'#7C3AED',
|
||||
'#8B5CF6',
|
||||
'#EC4899',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.event;
|
||||
_titleCtrl = TextEditingController(text: e?.title ?? '');
|
||||
_descCtrl = TextEditingController(text: e?.description ?? '');
|
||||
_locationCtrl = TextEditingController(text: e?.location ?? '');
|
||||
|
||||
if (e != null) {
|
||||
_startDt = e.startDt.toLocal();
|
||||
_endDt = e.endDt?.toLocal();
|
||||
_allDay = e.allDay;
|
||||
// Custom RRULEs are displayed as read-only; leave _recurrence = null.
|
||||
_recurrence = _isCustomRrule ? null : e.recurrence;
|
||||
_color = e.color;
|
||||
} else {
|
||||
final base = widget.initialDate ?? DateTime.now();
|
||||
final now = DateTime.now();
|
||||
final hour = now.minute >= 30 ? (now.hour + 1) % 24 : now.hour;
|
||||
_startDt = DateTime(base.year, base.month, base.day, hour);
|
||||
_allDay = false;
|
||||
_recurrence = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
_locationCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_titleCtrl.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Title is required.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
// Preserve unrecognised RRULEs unchanged on save.
|
||||
final rrule = _isCustomRrule ? widget.event!.recurrence : _recurrence;
|
||||
final payload = <String, dynamic>{
|
||||
'title': _titleCtrl.text.trim(),
|
||||
'start_dt': _startDt.toUtc().toIso8601String(),
|
||||
if (_endDt != null) 'end_dt': _endDt!.toUtc().toIso8601String(),
|
||||
'all_day': _allDay,
|
||||
'description': _descCtrl.text.trim(),
|
||||
'location': _locationCtrl.text.trim(),
|
||||
'color': _color,
|
||||
'recurrence': rrule,
|
||||
};
|
||||
final repo = ref.read(eventsRepositoryProvider);
|
||||
if (_isCreate) {
|
||||
final created = await repo.createEvent(payload);
|
||||
widget.notifier.addEvent(created);
|
||||
} else {
|
||||
final updated = await repo.updateEvent(widget.event!.id, payload);
|
||||
widget.notifier.updateEvent(updated);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to save event.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _delete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete this event?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(eventsRepositoryProvider).deleteEvent(widget.event!.id);
|
||||
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to delete event.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Date/time pickers ─────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _pickStartDate() async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _startDt,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d != null) {
|
||||
setState(() {
|
||||
_startDt = DateTime(
|
||||
d.year, d.month, d.day, _startDt.hour, _startDt.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickStartTime() async {
|
||||
final t = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_startDt),
|
||||
);
|
||||
if (t != null) {
|
||||
setState(() {
|
||||
_startDt = DateTime(
|
||||
_startDt.year, _startDt.month, _startDt.day, t.hour, t.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickEndDate() async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _endDt ?? _startDt,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d != null) {
|
||||
setState(() {
|
||||
final prev = _endDt ?? _startDt;
|
||||
_endDt =
|
||||
DateTime(d.year, d.month, d.day, prev.hour, prev.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickEndTime() async {
|
||||
final t = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_endDt ?? _startDt),
|
||||
);
|
||||
if (t != null) {
|
||||
setState(() {
|
||||
final prev = _endDt ?? _startDt;
|
||||
_endDt = DateTime(
|
||||
prev.year, prev.month, prev.day, t.hour, t.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Formatting helpers ────────────────────────────────────────────────────
|
||||
|
||||
String _fmtDate(DateTime dt) {
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
return '${months[dt.month - 1]} ${dt.day}, ${dt.year}';
|
||||
}
|
||||
|
||||
String _fmtTime(DateTime dt) =>
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
// ── Build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_isCreate ? 'New Event' : 'Edit Event',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
if (!_isCreate)
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
onPressed: _saving ? null : _delete,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Title
|
||||
TextField(
|
||||
controller: _titleCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// All-day toggle
|
||||
SwitchListTile(
|
||||
title: const Text('All day'),
|
||||
value: _allDay,
|
||||
onChanged: (v) => setState(() => _allDay = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
// Start date
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.calendarDays),
|
||||
title: Text(_fmtDate(_startDt)),
|
||||
subtitle: const Text('Start date'),
|
||||
onTap: _pickStartDate,
|
||||
),
|
||||
|
||||
// Start time (hidden when all-day)
|
||||
if (!_allDay)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.clock),
|
||||
title: Text(_fmtTime(_startDt)),
|
||||
subtitle: const Text('Start time'),
|
||||
onTap: _pickStartTime,
|
||||
),
|
||||
|
||||
// End date
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.calendarCheck),
|
||||
title: Text(
|
||||
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
|
||||
subtitle: const Text('End date'),
|
||||
onTap: _pickEndDate,
|
||||
trailing: _endDt != null
|
||||
? IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => setState(() => _endDt = null),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
// End time (hidden when all-day or no end date)
|
||||
if (!_allDay && _endDt != null)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.clock),
|
||||
title: Text(_fmtTime(_endDt!)),
|
||||
subtitle: const Text('End time'),
|
||||
onTap: _pickEndTime,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Repeat — show read-only tile for custom RRULEs
|
||||
if (_isCustomRrule)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.repeat),
|
||||
title: const Text('Custom (read-only)'),
|
||||
subtitle: Text(widget.event!.recurrence ?? ''),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.repeat),
|
||||
const SizedBox(width: 16),
|
||||
DropdownButton<String?>(
|
||||
value: _recurrence,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: _knownRrules.entries
|
||||
.map((entry) => DropdownMenuItem<String?>(
|
||||
value: entry.key,
|
||||
child: Text(entry.value),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _recurrence = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Description
|
||||
TextField(
|
||||
controller: _descCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Location
|
||||
TextField(
|
||||
controller: _locationCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Location',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Color chips
|
||||
Text('Color', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
// "No color" chip
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _color = ''),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: _color.isEmpty
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
width: _color.isEmpty ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: const Icon(LucideIcons.ban, size: 16),
|
||||
),
|
||||
),
|
||||
..._presetColors.map((hex) {
|
||||
final c =
|
||||
Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _color = hex),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: c,
|
||||
shape: BoxShape.circle,
|
||||
border: _color == hex
|
||||
? Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Save button — Moss action-primary per Hybrid rule
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).extension<ActionColors>()!.primary,
|
||||
),
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_isCreate ? 'Create' : 'Save'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.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;
|
||||
@@ -13,12 +17,56 @@ class ChatScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
class _ChatScreenState extends ConsumerState<ChatScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _refreshMessages() async {
|
||||
if (_refreshing) return;
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
await ref
|
||||
.read(messagesProvider(widget.conversationId).notifier)
|
||||
.refresh();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not refresh messages.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// If we land on a conversation whose last assistant message is already
|
||||
// mid-stream (e.g. the /news discuss button creates a conv and
|
||||
// auto-kicks generation), attach to the running stream so the user sees
|
||||
// live tokens instead of a frozen placeholder.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ref
|
||||
.read(messagesProvider(widget.conversationId).notifier)
|
||||
.attachToGeneration();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.read(messagesProvider(widget.conversationId).notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
@@ -58,16 +106,54 @@ 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 streamingStatus =
|
||||
ref.watch(streamingStatusProvider(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
|
||||
@@ -78,6 +164,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Refresh',
|
||||
onPressed: _refreshing ? null : _refreshMessages,
|
||||
icon: _refreshing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(LucideIcons.refreshCw),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
@@ -85,25 +184,55 @@ 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) {
|
||||
if (messages.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Send a message to start.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 12),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, i) =>
|
||||
ChatMessageBubble(message: messages[i]),
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshMessages,
|
||||
child: messages.isEmpty
|
||||
? ListView(
|
||||
// Needs to be scrollable for RefreshIndicator to
|
||||
// fire on empty state — plain Center won't work.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 240),
|
||||
Center(child: Text('Send a message to start.')),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 12),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, i) => ChatMessageBubble(
|
||||
message: messages[i],
|
||||
streamingStatus: (i == messages.length - 1 &&
|
||||
messages[i].status == 'generating')
|
||||
? streamingStatus
|
||||
: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// 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 +243,37 @@ 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,
|
||||
amplitude: voiceState.amplitude,
|
||||
onTap: _toggleVoiceMode,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
IconButton.filled(
|
||||
onPressed: isStreaming ? null : _send,
|
||||
onPressed: (isStreaming || voiceState.voiceModeActive)
|
||||
? null
|
||||
: _send,
|
||||
icon: isStreaming
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
@@ -138,7 +281,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
: const Icon(LucideIcons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -149,4 +292,3 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
class ConversationsTabScreen extends ConsumerWidget {
|
||||
class ConversationsTabScreen extends ConsumerStatefulWidget {
|
||||
const ConversationsTabScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ConversationsTabScreen> createState() =>
|
||||
_ConversationsTabScreenState();
|
||||
}
|
||||
|
||||
class _ConversationsTabScreenState
|
||||
extends ConsumerState<ConversationsTabScreen> {
|
||||
int? _selectedConvId;
|
||||
|
||||
Future<void> _createConversation() async {
|
||||
final conv =
|
||||
await ref.read(conversationsProvider.notifier).create('');
|
||||
if (!mounted) return;
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = conv.id);
|
||||
} else {
|
||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
}
|
||||
|
||||
void _openConversation(int id) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = id);
|
||||
} else {
|
||||
context.push(Routes.chat.replaceFirst(':id', '$id'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(int id, String title) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text('"$title" will be permanently deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||
),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(conversationsProvider.notifier).delete(id);
|
||||
if (_selectedConvId == id) {
|
||||
setState(() => _selectedConvId = null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
final theme = Theme.of(context);
|
||||
final convsAsync = ref.watch(conversationsProvider);
|
||||
|
||||
return Scaffold(
|
||||
final listPanel = Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat', style: theme.textTheme.titleLarge),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
tooltip: 'New conversation',
|
||||
onPressed: () async {
|
||||
final conv = await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.create('New conversation');
|
||||
if (context.mounted) {
|
||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
},
|
||||
onPressed: _createConversation,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -40,50 +94,45 @@ class ConversationsTabScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
Icon(LucideIcons.messageCircle,
|
||||
size: 48, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('No conversations yet',
|
||||
style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: const Text('Start a conversation'),
|
||||
onPressed: () async {
|
||||
final conv = await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.create('New conversation');
|
||||
if (context.mounted) {
|
||||
context.push(
|
||||
Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
},
|
||||
onPressed: _createConversation,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(conversationsProvider),
|
||||
onRefresh: () =>
|
||||
ref.read(conversationsProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
itemCount: convs.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final c = convs[i];
|
||||
final selected = isWide && c.id == _selectedConvId;
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(c.title,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
leading: const Icon(LucideIcons.messageCircle),
|
||||
title: Text(
|
||||
c.title.isEmpty ? 'New conversation' : c.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
_relativeTime(c.updatedAt),
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
selected: selected,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () =>
|
||||
_confirmDelete(context, ref, c.id, c.title),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () => _confirmDelete(c.id, c.title),
|
||||
),
|
||||
onTap: () =>
|
||||
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
|
||||
onTap: () => _openConversation(c.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -91,28 +140,38 @@ class ConversationsTabScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, WidgetRef ref, int id, String title) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text('"$title" will be permanently deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
if (!isWide) return listPanel;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: listPanel,
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: _selectedConvId != null
|
||||
? ChatScreen(
|
||||
key: ValueKey(_selectedConvId),
|
||||
conversationId: _selectedConvId!,
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.messageCircle,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('Select a conversation',
|
||||
style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(conversationsProvider.notifier).delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/models/journal_day.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
|
||||
class JournalHistoryScreen extends ConsumerWidget {
|
||||
const JournalHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final daysAsync = ref.watch(_journalDaysProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Past Days')),
|
||||
body: daysAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) =>
|
||||
const Center(child: Text('Could not load journal history.')),
|
||||
data: (days) {
|
||||
if (days.isEmpty) {
|
||||
return const Center(child: Text('No past days.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: days.length,
|
||||
itemBuilder: (context, i) {
|
||||
final isoDate = days[i];
|
||||
return ListTile(
|
||||
leading: const Icon(LucideIcons.bookOpen),
|
||||
title: Text(isoDate),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _JournalDayDetailScreen(isoDate: isoDate),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JournalDayDetailScreen extends ConsumerWidget {
|
||||
final String isoDate;
|
||||
const _JournalDayDetailScreen({required this.isoDate});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final dayAsync = ref.watch(_journalDayProvider(isoDate));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(isoDate)),
|
||||
body: dayAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) =>
|
||||
const Center(child: Text('Could not load that day.')),
|
||||
data: (day) {
|
||||
if (day.messages.isEmpty) {
|
||||
return const Center(child: Text('No messages.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
itemCount: day.messages.length,
|
||||
itemBuilder: (_, i) => ChatMessageBubble(message: day.messages[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private providers (scoped to this file) ──────────────────────────────────
|
||||
|
||||
final _journalDaysProvider = FutureProvider<List<String>>((ref) async {
|
||||
return ref.watch(journalApiProvider).getDays();
|
||||
});
|
||||
|
||||
final _journalDayProvider =
|
||||
FutureProvider.family<JournalDay, String>((ref, isoDate) async {
|
||||
return ref.watch(journalApiProvider).getDay(isoDate);
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/models/message.dart';
|
||||
import '../../providers/journal_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
import '../../widgets/voice_mic_button.dart';
|
||||
import 'journal_history_screen.dart';
|
||||
|
||||
class JournalScreen extends ConsumerStatefulWidget {
|
||||
const JournalScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<JournalScreen> createState() => _JournalScreenState();
|
||||
}
|
||||
|
||||
class _JournalScreenState extends ConsumerState<JournalScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _refreshing = false;
|
||||
|
||||
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) {
|
||||
final wasBackground = !_appInForeground;
|
||||
_appInForeground = state == AppLifecycleState.resumed;
|
||||
if (_appInForeground && wasBackground && mounted) {
|
||||
ref.read(journalProvider.notifier).refreshMessages();
|
||||
}
|
||||
}
|
||||
|
||||
void _pollSilently() {
|
||||
if (!_appInForeground || !mounted) return;
|
||||
final isStreaming = ref.read(isJournalStreamingProvider);
|
||||
if (isStreaming) return;
|
||||
ref.read(journalProvider.notifier).silentRefresh();
|
||||
}
|
||||
|
||||
Future<void> _pullToRefresh() async {
|
||||
try {
|
||||
await ref.read(journalProvider.notifier).refreshMessages();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not refresh.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendReply() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
try {
|
||||
await ref.read(journalProvider.notifier).sendReply(text);
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to send reply.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(journalProvider.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 {
|
||||
await ref.read(journalProvider.notifier).regeneratePrep();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not regenerate prep.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final journalAsync = ref.watch(journalProvider);
|
||||
final isStreaming = ref.watch(isJournalStreamingProvider);
|
||||
final voiceState = ref.watch(voiceProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
ref.listen(journalProvider, (prev, next) => _scrollToBottom());
|
||||
|
||||
// Feed streaming assistant content to VoiceNotifier for TTS.
|
||||
ref.listen(journalProvider, (prev, next) {
|
||||
if (!voiceState.voiceModeActive) return;
|
||||
final day = next.value;
|
||||
if (day == null || day.messages.isEmpty) return;
|
||||
final last = day.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(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Journal', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_refreshing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: 'Regenerate prep',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'history') {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const JournalHistoryScreen(),
|
||||
));
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: 'history',
|
||||
child: Text('Past days'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: journalAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Could not load today's journal."),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(journalProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (day) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
Widget body = Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _pullToRefresh,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
if (day.messages.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'No prep 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: day.messages.length,
|
||||
itemBuilder: (_, i) =>
|
||||
_JournalMessageItem(message: day.messages[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isStreaming)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
if (voiceState.voiceModeActive)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
child: const Text(
|
||||
'🎤 Listening… tap mic to exit voice mode',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFF87171),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: voiceState.voiceModeActive
|
||||
? 'Listening…'
|
||||
: 'Tell your journal…',
|
||||
hintStyle: voiceState.voiceModeActive
|
||||
? const TextStyle(fontStyle: FontStyle.italic)
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
),
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.newline,
|
||||
enabled: !isStreaming && !voiceState.voiceModeActive,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
VoiceMicButton(
|
||||
mode: voiceState.mode,
|
||||
voiceModeActive: voiceState.voiceModeActive,
|
||||
amplitude: voiceState.amplitude,
|
||||
onTap: _toggleVoiceMode,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_GradientSendButton(
|
||||
onPressed: (isStreaming || voiceState.voiceModeActive)
|
||||
? null
|
||||
: _sendReply,
|
||||
isStreaming: isStreaming,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (isWide) {
|
||||
body = Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 700),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
return body;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _todayLabel() {
|
||||
final now = DateTime.now();
|
||||
const days = [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'
|
||||
];
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a single journal message. The daily-prep prose itself already
|
||||
/// covers weather ("Weather at home will reach a high of 15.9° ..."), so
|
||||
/// the journal screen leaves rendering to the message bubble — no separate
|
||||
/// weather card on top.
|
||||
class _JournalMessageItem extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
const _JournalMessageItem({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChatMessageBubble(message: message);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientSendButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final bool isStreaming;
|
||||
|
||||
const _GradientSendButton({
|
||||
required this.onPressed,
|
||||
required this.isStreaming,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final disabled = onPressed == null;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: disabled
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF5B4A8A), Color(0xFF3F3560)],
|
||||
),
|
||||
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: isStreaming
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
LucideIcons.send,
|
||||
color: disabled
|
||||
? scheme.onSurface.withValues(alpha: 0.38)
|
||||
: Colors.white,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.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) => _kTabs[index].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 ? LucideIcons.x : LucideIcons.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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(LucideIcons.pencil),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cols = constraints.maxWidth >= 900
|
||||
? 3
|
||||
: constraints.maxWidth >= 600
|
||||
? 2
|
||||
: 1;
|
||||
if (cols == 1) {
|
||||
return 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]);
|
||||
},
|
||||
);
|
||||
}
|
||||
return CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1.8,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(_, i) {
|
||||
if (i >= items.length) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator());
|
||||
}
|
||||
return KnowledgeItemCard(item: items[i])
|
||||
.buildGridCard(context);
|
||||
},
|
||||
childCount:
|
||||
items.length + (state.isLoadingBatch ? 1 : 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: LucideIcons.fileText,
|
||||
label: 'Note',
|
||||
description: 'General note or document',
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
context.push('/notes/new', extra: {'noteType': 'note'});
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: LucideIcons.user,
|
||||
label: 'Person',
|
||||
description: 'Contact, colleague, or reference person',
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
context.push('/notes/new', extra: {'noteType': 'person'});
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
label: 'Place',
|
||||
description: 'Location, venue, or place of interest',
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
context.push('/notes/new', extra: {'noteType': 'place'});
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: LucideIcons.listChecks,
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../data/local/database.dart';
|
||||
import '../../data/models/milestone.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../data/models/task.dart';
|
||||
@@ -10,6 +12,7 @@ import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/milestones_provider.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../widgets/pending_sync_badge.dart';
|
||||
|
||||
class ProjectTasksScreen extends ConsumerStatefulWidget {
|
||||
final int projectId;
|
||||
@@ -48,12 +51,18 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _openTask(int taskId) {
|
||||
context
|
||||
.push(Routes.taskEdit.replaceFirst(':id', '$taskId'))
|
||||
.then((_) => ref.invalidate(projectTasksProvider(widget.projectId)));
|
||||
}
|
||||
|
||||
Color _parseColor(String? hex) {
|
||||
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
|
||||
if (hex == null || hex.isEmpty) return const Color(0xFF5B4A8A);
|
||||
try {
|
||||
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||
} catch (_) {
|
||||
return const Color(0xFF6366F1);
|
||||
return const Color(0xFF5B4A8A);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +97,14 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.pencil),
|
||||
tooltip: 'Edit project',
|
||||
onPressed: () =>
|
||||
context.push('/projects/${widget.projectId}/edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: tasksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@@ -119,7 +136,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_box_outlined,
|
||||
Icon(LucideIcons.checkSquare,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
@@ -137,8 +154,10 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
setState(() => _pendingStatus.clear());
|
||||
ref.invalidate(projectTasksProvider(widget.projectId));
|
||||
ref.invalidate(projectMilestonesProvider(widget.projectId));
|
||||
await Future.wait([
|
||||
ref.refresh(projectTasksProvider(widget.projectId).future),
|
||||
ref.refresh(projectMilestonesProvider(widget.projectId).future),
|
||||
]);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -163,6 +182,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
onTap: () => _openTask(task.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -190,6 +210,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
onTap: () => _openTask(task.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -274,17 +295,19 @@ class _TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
final TaskStatus effectiveStatus;
|
||||
final VoidCallback onStatusTap;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TaskRow({
|
||||
required this.task,
|
||||
required this.effectiveStatus,
|
||||
required this.onStatusTap,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
IconData get _statusIcon => switch (effectiveStatus) {
|
||||
TaskStatus.done => Icons.check_circle,
|
||||
TaskStatus.inProgress => Icons.timelapse,
|
||||
TaskStatus.todo => Icons.radio_button_unchecked,
|
||||
TaskStatus.done => LucideIcons.checkCircle2,
|
||||
TaskStatus.inProgress => LucideIcons.loader,
|
||||
TaskStatus.todo => LucideIcons.circle,
|
||||
};
|
||||
|
||||
Color _statusColor(BuildContext context) {
|
||||
@@ -309,8 +332,7 @@ class _TaskRow extends StatelessWidget {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
child: InkWell(
|
||||
onTap: () => context
|
||||
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
|
||||
@@ -326,18 +348,28 @@ class _TaskRow extends StatelessWidget {
|
||||
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,
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
PendingSyncBadge(
|
||||
domain: kSyncDomainTasks,
|
||||
id: task.id,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (task.dueDate != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../core/wikilink_syntax.dart';
|
||||
import '../../providers/notes_provider.dart';
|
||||
|
||||
@@ -44,13 +46,13 @@ class NoteDetailScreen extends ConsumerWidget {
|
||||
data: (note) => Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
icon: const Icon(LucideIcons.pencil),
|
||||
onPressed: () => context.push(
|
||||
Routes.noteEdit.replaceFirst(':id', '$noteId'),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -65,6 +67,11 @@ class NoteDetailScreen extends ConsumerWidget {
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext)
|
||||
.extension<ActionColors>()!
|
||||
.destructive,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../core/wikilink_syntax.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/notes_provider.dart';
|
||||
@@ -11,7 +13,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 +28,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 +55,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) {
|
||||
@@ -81,6 +87,9 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
@@ -108,6 +117,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
body,
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
noteType: _noteType,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
} else {
|
||||
@@ -118,6 +128,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
clearProject: _projectId == null,
|
||||
noteType: _noteType,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
@@ -138,16 +149,31 @@ 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(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
tooltip: 'Delete',
|
||||
onPressed: _delete,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(_preview ? Icons.edit : Icons.preview),
|
||||
icon: Icon(_preview ? LucideIcons.pencil : LucideIcons.eye),
|
||||
tooltip: _preview ? 'Edit' : 'Preview',
|
||||
onPressed: () => setState(() => _preview = !_preview),
|
||||
),
|
||||
@@ -158,7 +184,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
: const Icon(LucideIcons.check),
|
||||
onPressed: _saving ? null : _save,
|
||||
),
|
||||
],
|
||||
@@ -251,7 +277,7 @@ class _TagInput extends StatelessWidget {
|
||||
label: Text('#$tag', style: const TextStyle(fontSize: 12)),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
deleteIcon: const Icon(Icons.close, size: 14),
|
||||
deleteIcon: const Icon(LucideIcons.x, size: 14),
|
||||
onDeleted: () => onRemove(tag),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.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(LucideIcons.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,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../data/local/database.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../widgets/pending_sync_badge.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: () => ref.read(projectsProvider.notifier).refresh(),
|
||||
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(LucideIcons.plus),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: Row(
|
||||
children: [
|
||||
Flexible(child: Text(project.title)),
|
||||
PendingSyncBadge(domain: kSyncDomainProjects, id: project.id),
|
||||
],
|
||||
),
|
||||
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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -24,28 +25,28 @@ class SettingsScreen extends ConsumerWidget {
|
||||
ListTile(
|
||||
title: const Text('Server URL'),
|
||||
subtitle: Text(serverUrl ?? 'Not configured'),
|
||||
leading: const Icon(Icons.dns),
|
||||
leading: const Icon(LucideIcons.server),
|
||||
onTap: () => context.go(Routes.setup),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text('Appearance'),
|
||||
leading: const Icon(Icons.brightness_6),
|
||||
leading: const Icon(LucideIcons.sunMoon),
|
||||
trailing: SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
icon: Icon(Icons.brightness_auto),
|
||||
icon: Icon(LucideIcons.contrast),
|
||||
tooltip: 'System',
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
icon: Icon(Icons.light_mode),
|
||||
icon: Icon(LucideIcons.sun),
|
||||
tooltip: 'Light',
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
icon: Icon(Icons.dark_mode),
|
||||
icon: Icon(LucideIcons.moon),
|
||||
tooltip: 'Dark',
|
||||
),
|
||||
],
|
||||
@@ -58,7 +59,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
// ── Updates ──────────────────────────────────────────────────────
|
||||
ListTile(
|
||||
leading: const Icon(Icons.update),
|
||||
leading: const Icon(LucideIcons.refreshCw),
|
||||
title: const Text('Update repository'),
|
||||
subtitle: Text(repoUrl?.isNotEmpty == true
|
||||
? repoUrl!
|
||||
@@ -66,7 +67,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onTap: () => _editRepoUrl(context, ref, repoUrl),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
leading: const Icon(LucideIcons.info),
|
||||
title: const Text('App version'),
|
||||
subtitle: _versionSubtitle(update),
|
||||
trailing: update.status == UpdateStatus.checking
|
||||
@@ -92,13 +93,13 @@ class SettingsScreen extends ConsumerWidget {
|
||||
child: const Text('Check'),
|
||||
),
|
||||
),
|
||||
if (update.status == UpdateStatus.available ||
|
||||
if (update.status == UpdateStatus.readyToInstall ||
|
||||
update.status == UpdateStatus.downloading)
|
||||
_UpdateTile(update: update),
|
||||
if (update.status == UpdateStatus.error)
|
||||
ListTile(
|
||||
leading:
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const Icon(LucideIcons.alertCircle, color: Colors.red),
|
||||
title: const Text('Update check failed'),
|
||||
subtitle: Text(
|
||||
update.errorMessage ?? 'Unknown error',
|
||||
@@ -110,7 +111,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text('Sign Out'),
|
||||
leading: const Icon(Icons.logout),
|
||||
leading: const Icon(LucideIcons.logOut),
|
||||
onTap: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) context.go(Routes.login);
|
||||
@@ -131,7 +132,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
if (update.status == UpdateStatus.upToDate) {
|
||||
return Text('v$current — up to date');
|
||||
}
|
||||
if (update.status == UpdateStatus.available ||
|
||||
if (update.status == UpdateStatus.readyToInstall ||
|
||||
update.status == UpdateStatus.downloading) {
|
||||
return Text('v$current installed');
|
||||
}
|
||||
@@ -184,7 +185,7 @@ class _UpdateTile extends ConsumerWidget {
|
||||
final isDownloading = update.status == UpdateStatus.downloading;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.system_update, color: Colors.green),
|
||||
leading: const Icon(LucideIcons.downloadCloud, color: Colors.green),
|
||||
title: Text('v${update.latestVersion} available'),
|
||||
subtitle: isDownloading
|
||||
? Column(
|
||||
@@ -202,12 +203,12 @@ class _UpdateTile extends ConsumerWidget {
|
||||
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
|
||||
],
|
||||
)
|
||||
: const Text('Tap to download and install'),
|
||||
: const Text('Ready to install'),
|
||||
trailing: isDownloading
|
||||
? null
|
||||
: FilledButton(
|
||||
onPressed: () =>
|
||||
ref.read(updateProvider.notifier).downloadAndInstall(),
|
||||
ref.read(updateProvider.notifier).install(),
|
||||
child: const Text('Install'),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
await ref.read(authProvider.notifier).verify();
|
||||
if (!mounted) return;
|
||||
final status = ref.read(authProvider);
|
||||
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
|
||||
if (status == AuthStatus.authenticated) {
|
||||
context.go(Routes.briefing);
|
||||
context.go(Routes.journal);
|
||||
} else if (status == AuthStatus.offline && hasEverLoggedIn) {
|
||||
// Server unreachable but this user has logged in before — land them on
|
||||
// the briefing with the offline banner rather than the login screen.
|
||||
context.go(Routes.journal);
|
||||
} else {
|
||||
context.go(Routes.login);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../core/theme.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
@@ -111,6 +113,9 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
|
||||
),
|
||||
child: const Text('Delete')),
|
||||
],
|
||||
),
|
||||
@@ -190,7 +195,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
actions: [
|
||||
if (widget.taskId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
icon: const Icon(LucideIcons.trash2),
|
||||
onPressed: _delete,
|
||||
),
|
||||
IconButton(
|
||||
@@ -200,7 +205,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
: const Icon(LucideIcons.check),
|
||||
onPressed: _saving ? null : _save,
|
||||
),
|
||||
],
|
||||
@@ -272,10 +277,10 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
title: Text(_dueDate == null
|
||||
? 'No due date'
|
||||
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
||||
leading: const Icon(Icons.calendar_today),
|
||||
leading: const Icon(LucideIcons.calendarDays),
|
||||
trailing: _dueDate != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () =>
|
||||
setState(() => _dueDate = null),
|
||||
)
|
||||
@@ -331,7 +336,7 @@ class _SubTasksSection extends ConsumerWidget {
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
icon: const Icon(LucideIcons.plus, size: 16),
|
||||
label: const Text('Add'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import 'dart:math' show min;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/message.dart';
|
||||
import '../providers/api_client_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import 'tool_call_chip.dart';
|
||||
|
||||
class ChatMessageBubble extends StatelessWidget {
|
||||
class ChatMessageBubble extends ConsumerWidget {
|
||||
final Message message;
|
||||
const ChatMessageBubble({super.key, required this.message});
|
||||
final String streamingStatus;
|
||||
const ChatMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.streamingStatus = '',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isUser = message.role == MessageRole.user;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final serverUrl = ref.watch(serverUrlProvider) ?? '';
|
||||
final dio = ref.watch(dioProvider);
|
||||
final isGenerating = message.status == 'generating';
|
||||
final toolCalls = message.toolCalls ?? const [];
|
||||
|
||||
// An assistant bubble with no text, no tool calls, and still generating
|
||||
// falls back to the spinner+status "waiting for the first token" view.
|
||||
final showSpinnerOnly =
|
||||
isGenerating && message.content.isEmpty && toolCalls.isEmpty;
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
@@ -22,14 +41,16 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||
// Illuminated Transcript pattern (mirrors web's ChatMessage.vue):
|
||||
// - User bubble: transparent bg, neutral Pewter border, only the
|
||||
// bottom-right corner clipped (the "from-me" tail).
|
||||
// - Assistant bubble: card surface, 2px accent left edge (the
|
||||
// "illuminated capital"), accent-tinted glow shadow + depth
|
||||
// shadow, only the bottom-left corner clipped.
|
||||
decoration: isUser
|
||||
? BoxDecoration(
|
||||
// Ghost style: transparent bg, thin border
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
border: Border.all(color: scheme.outline, width: 1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
@@ -38,42 +59,201 @@ class ChatMessageBubble extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
: BoxDecoration(
|
||||
// Assistant: elevated surface + left accent border
|
||||
color: scheme.surfaceContainerHighest,
|
||||
color: scheme.surface,
|
||||
border: Border(
|
||||
left: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: scheme.primary.withValues(alpha: 0.14),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: isGenerating && message.content.isEmpty
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
: MarkdownBody(
|
||||
data: message.content.isEmpty ? '…' : message.content,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: TextStyle(
|
||||
color: isUser
|
||||
? scheme.onSurface.withValues(alpha: 0.75)
|
||||
: scheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
child: showSpinnerOnly
|
||||
? _buildSpinner(scheme)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Accumulated tool-call chips: visible both during
|
||||
// streaming (fed live over SSE) and after reload (from
|
||||
// the persisted message.tool_calls array).
|
||||
if (toolCalls.isNotEmpty) ...[
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (final tc in toolCalls) ToolCallChip(toolCall: tc),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
// Rolling status line — shows backend stage text
|
||||
// ("Creating note", "Searching calendar") while the
|
||||
// model is between tool rounds or just before the
|
||||
// first token. Stays above any already-streamed text
|
||||
// so the user can see what's happening mid-turn.
|
||||
if (isGenerating && streamingStatus.isNotEmpty) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
streamingStatus,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (message.content.isNotEmpty)
|
||||
MarkdownBody(
|
||||
data: message.content,
|
||||
imageBuilder: (uri, title, alt) {
|
||||
return _AuthImage(
|
||||
uri: uri,
|
||||
alt: alt,
|
||||
serverUrl: serverUrl,
|
||||
dio: dio,
|
||||
);
|
||||
},
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: TextStyle(
|
||||
color: isUser
|
||||
? scheme.onSurface.withValues(alpha: 0.75)
|
||||
: scheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpinner(ColorScheme scheme) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (streamingStatus.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
streamingStatus,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthImage extends StatefulWidget {
|
||||
final Uri uri;
|
||||
final String? alt;
|
||||
final String serverUrl;
|
||||
final Dio dio;
|
||||
|
||||
const _AuthImage({
|
||||
required this.uri,
|
||||
this.alt,
|
||||
required this.serverUrl,
|
||||
required this.dio,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AuthImage> createState() => _AuthImageState();
|
||||
}
|
||||
|
||||
class _AuthImageState extends State<_AuthImage> {
|
||||
late Future<Uint8List> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _fetchImage();
|
||||
}
|
||||
|
||||
Future<Uint8List> _fetchImage() async {
|
||||
var url = widget.uri.toString();
|
||||
if (url.startsWith('/')) {
|
||||
url = '${widget.serverUrl}$url';
|
||||
}
|
||||
final response = await widget.dio.get<List<int>>(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return Uint8List.fromList(response.data!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<Uint8List>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox(
|
||||
height: 100,
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError || !snapshot.hasData) {
|
||||
return Text(widget.alt ?? 'Image failed to load');
|
||||
}
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.memory(
|
||||
snapshot.data!,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) =>
|
||||
Text(widget.alt ?? 'Image failed to load'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
|
||||
import '../data/models/message.dart';
|
||||
|
||||
class BriefingDigestCard extends StatefulWidget {
|
||||
/// The first assistant message from today's briefing, or null if none yet.
|
||||
class JournalPrepCard extends StatefulWidget {
|
||||
/// The first assistant message from today's journal — the daily prep
|
||||
/// (LLM-generated briefing-style opener), or null if not yet generated.
|
||||
final Message? message;
|
||||
|
||||
/// Called when the user taps "Generate now".
|
||||
final VoidCallback? onGenerateNow;
|
||||
|
||||
const BriefingDigestCard({
|
||||
const JournalPrepCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onGenerateNow,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
|
||||
State<JournalPrepCard> createState() => _JournalPrepCardState();
|
||||
}
|
||||
|
||||
class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
||||
class _JournalPrepCardState extends State<JournalPrepCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
@@ -43,10 +45,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
|
||||
Icon(LucideIcons.bookOpen, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
@@ -57,11 +58,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Body
|
||||
if (widget.message == null) ...[
|
||||
Text(
|
||||
'No briefing yet today.',
|
||||
'No prep yet today.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../data/local/database.dart';
|
||||
import '../data/models/knowledge_item.dart';
|
||||
import 'pending_sync_badge.dart';
|
||||
|
||||
class KnowledgeItemCard extends StatelessWidget {
|
||||
final KnowledgeItem item;
|
||||
const KnowledgeItemCard({super.key, required this.item});
|
||||
|
||||
String get _pendingDomain =>
|
||||
item.noteType == 'task' ? kSyncDomainTasks : kSyncDomainNotes;
|
||||
|
||||
IconData get _icon => switch (item.noteType) {
|
||||
'person' => LucideIcons.user,
|
||||
'place' => LucideIcons.mapPin,
|
||||
'list' => LucideIcons.listChecks,
|
||||
'task' => LucideIcons.checkCircle2,
|
||||
_ => LucideIcons.fileText,
|
||||
};
|
||||
|
||||
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.body.trim().isNotEmpty) {
|
||||
final preview = item.body.trim().replaceAll('\n', ' ');
|
||||
return preview.length > 200 ? '${preview.substring(0, 200)}…' : preview;
|
||||
}
|
||||
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 > 200 ? '${preview.substring(0, 200)}…' : preview;
|
||||
}
|
||||
|
||||
void _onTap(BuildContext context) {
|
||||
if (item.noteType == 'task') {
|
||||
context.push('/tasks/${item.id}/edit');
|
||||
} else {
|
||||
context.push('/notes/${item.id}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(_icon, color: _statusColor(context)),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.title.isEmpty ? '(untitled)' : item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
PendingSyncBadge(domain: _pendingDomain, id: item.id),
|
||||
],
|
||||
),
|
||||
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: () => _onTap(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildGridCard(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () => _onTap(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_icon, size: 18, color: _statusColor(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title.isEmpty ? '(untitled)' : item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
PendingSyncBadge(domain: _pendingDomain, id: item.id),
|
||||
],
|
||||
),
|
||||
if (_subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_subtitle!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 4,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.tags.isNotEmpty) ...[
|
||||
const Spacer(),
|
||||
_TagChips(tags: item.tags),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,272 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../core/constants.dart';
|
||||
import '../data/models/note.dart';
|
||||
import '../data/models/project.dart';
|
||||
import '../data/models/task.dart';
|
||||
import '../providers/tasks_provider.dart';
|
||||
|
||||
// ── Note card ────────────────────────────────────────────────────────────────
|
||||
|
||||
class NoteLibraryCard extends StatelessWidget {
|
||||
final Note note;
|
||||
const NoteLibraryCard({super.key, required this.note});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: () => context
|
||||
.push(Routes.noteDetail.replaceFirst(':id', '${note.id}')),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.article_outlined,
|
||||
size: 15, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
note.title.isNotEmpty ? note.title : 'Untitled',
|
||||
style: theme.textTheme.titleSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_relativeTime(note.updatedAt),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (note.body.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
note.body.replaceAll('\n', ' '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (note.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 2,
|
||||
children: note.tags
|
||||
.take(4)
|
||||
.map((t) => Chip(
|
||||
label: Text(t),
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task card ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TaskLibraryCard extends ConsumerWidget {
|
||||
final Task task;
|
||||
const TaskLibraryCard({super.key, required this.task});
|
||||
|
||||
Color _priorityColor(BuildContext context) {
|
||||
return switch (task.priority) {
|
||||
TaskPriority.high => const Color(0xFFEF4444),
|
||||
TaskPriority.medium => const Color(0xFFF59E0B),
|
||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
};
|
||||
}
|
||||
|
||||
IconData get _statusIcon => switch (task.status) {
|
||||
TaskStatus.done => Icons.check_circle,
|
||||
TaskStatus.inProgress => Icons.timelapse,
|
||||
_ => Icons.radio_button_unchecked,
|
||||
};
|
||||
|
||||
Color _statusColor(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return switch (task.status) {
|
||||
TaskStatus.done => const Color(0xFF22C55E),
|
||||
TaskStatus.inProgress => cs.primary,
|
||||
_ => cs.onSurfaceVariant,
|
||||
};
|
||||
}
|
||||
|
||||
TaskStatus get _nextStatus => switch (task.status) {
|
||||
TaskStatus.todo => TaskStatus.inProgress,
|
||||
TaskStatus.inProgress => TaskStatus.done,
|
||||
_ => TaskStatus.todo,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: () => context
|
||||
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// Status cycle button
|
||||
IconButton(
|
||||
icon: Icon(_statusIcon, color: _statusColor(context)),
|
||||
onPressed: () => ref
|
||||
.read(tasksProvider.notifier)
|
||||
.updateTask(task.id, {'status': _nextStatus.value}),
|
||||
tooltip: 'Cycle status',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.title.isNotEmpty ? task.title : 'Untitled',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
decoration: task.status == TaskStatus.done
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (task.dueDate != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Due ${_formatDate(task.dueDate!)}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: task.dueDate!.isBefore(DateTime.now()) &&
|
||||
task.status != TaskStatus.done
|
||||
? const Color(0xFFEF4444)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (task.priority != TaskPriority.none &&
|
||||
task.priority != TaskPriority.low)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _priorityColor(context),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project card ─────────────────────────────────────────────────────────────
|
||||
|
||||
class ProjectLibraryCard extends StatelessWidget {
|
||||
final Project project;
|
||||
const ProjectLibraryCard({super.key, required this.project});
|
||||
|
||||
Color _parseColor(String? hex) {
|
||||
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
|
||||
try {
|
||||
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||
} catch (_) {
|
||||
return const Color(0xFF6366F1);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = _parseColor(project.color);
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context
|
||||
.push('/projects/${project.id}/tasks'),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Row(
|
||||
children: [
|
||||
// Colour strip
|
||||
Container(width: 6, height: 64, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
project.title,
|
||||
style: theme.textTheme.titleSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (project.description?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
project.description!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
String _relativeTime(DateTime dt) {
|
||||
final diff = DateTime.now().difference(dt);
|
||||
if (diff.inMinutes < 1) return 'just now';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
||||
if (diff.inDays < 1) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||
return _formatDate(dt);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
return '${months[dt.month - 1]} ${dt.day}';
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/api_client_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// Sticky banner shown when the backend is unreachable. Surfaces a retry
|
||||
/// action plus a "last sync X ago" hint when there is cached content to
|
||||
/// fall back on (Tier 2 offline mode).
|
||||
class OfflineBanner extends ConsumerStatefulWidget {
|
||||
const OfflineBanner({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OfflineBanner> createState() => _OfflineBannerState();
|
||||
}
|
||||
|
||||
class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||
bool _retrying = false;
|
||||
DateTime? _lastSync;
|
||||
Timer? _refreshTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLastSync();
|
||||
// Refresh the relative-time string every 30s so "5 min ago" rolls
|
||||
// forward without the user having to interact with the banner.
|
||||
_refreshTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _loadLastSync(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadLastSync() async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
// Most-recent sync across all cached domains so the hint reads as
|
||||
// "your data was current as of X" regardless of which screen the
|
||||
// user is looking at.
|
||||
final ts = await ref.read(fabledDatabaseProvider).getLatestSync();
|
||||
if (!mounted) return;
|
||||
setState(() => _lastSync = ts);
|
||||
} catch (_) {
|
||||
// Non-critical — banner just won't show the sync hint.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
if (_retrying) return;
|
||||
setState(() => _retrying = true);
|
||||
try {
|
||||
await ref.read(authProvider.notifier).verify();
|
||||
} finally {
|
||||
if (mounted) setState(() => _retrying = false);
|
||||
}
|
||||
}
|
||||
|
||||
String? _relativeAgo(DateTime ts) {
|
||||
final diff = DateTime.now().difference(ts);
|
||||
if (diff.isNegative) return null;
|
||||
if (diff.inMinutes < 1) return 'just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes} min ago';
|
||||
if (diff.inHours < 24) {
|
||||
final h = diff.inHours;
|
||||
return '$h ${h == 1 ? 'hour' : 'hours'} ago';
|
||||
}
|
||||
final d = diff.inDays;
|
||||
return '$d ${d == 1 ? 'day' : 'days'} ago';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = ref.watch(authProvider);
|
||||
if (status != AuthStatus.offline) return const SizedBox.shrink();
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final ago = _lastSync == null ? null : _relativeAgo(_lastSync!);
|
||||
// Pending queue depth — shown on the Retry button so the user knows
|
||||
// there's offline work waiting to land when they come back online.
|
||||
final pending = ref.watch(writeQueueDepthProvider).asData?.value ?? 0;
|
||||
|
||||
final baseMessage = ago == null
|
||||
? 'Offline — showing cached data.'
|
||||
: 'Offline — last sync $ago.';
|
||||
final message = pending > 0
|
||||
? '$baseMessage $pending pending change${pending == 1 ? '' : 's'}.'
|
||||
: baseMessage;
|
||||
final retryLabel = pending > 0 ? 'Retry ($pending)' : 'Retry';
|
||||
|
||||
return Material(
|
||||
color: scheme.errorContainer,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.cloudOff,
|
||||
size: 18, color: scheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(color: scheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _retrying ? null : _retry,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: scheme.onErrorContainer,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: _retrying
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(retryLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/local/database.dart';
|
||||
import '../providers/api_client_provider.dart';
|
||||
|
||||
/// Small cloud-upload glyph shown next to a row whose id has a queued
|
||||
/// offline write (Phase 4). Renders nothing when the queue is empty for
|
||||
/// that id, so list views stay clean during normal online operation.
|
||||
class PendingSyncBadge extends ConsumerWidget {
|
||||
final String domain;
|
||||
final int id;
|
||||
final double size;
|
||||
|
||||
const PendingSyncBadge({
|
||||
super.key,
|
||||
required this.domain,
|
||||
required this.id,
|
||||
this.size = 14,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pending = switch (domain) {
|
||||
kSyncDomainNotes => ref.watch(pendingNoteIdsProvider).asData?.value,
|
||||
kSyncDomainTasks => ref.watch(pendingTaskIdsProvider).asData?.value,
|
||||
kSyncDomainProjects =>
|
||||
ref.watch(pendingProjectIdsProvider).asData?.value,
|
||||
_ => null,
|
||||
};
|
||||
if (pending == null || !pending.contains(id)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Tooltip(
|
||||
message: 'Pending sync — will save when online',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Icon(
|
||||
LucideIcons.uploadCloud,
|
||||
size: size,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../core/constants.dart';
|
||||
|
||||
/// Human-readable labels for each backend tool. Kept in sync with
|
||||
/// `_TOOL_LABELS` in `fabledassistant/services/generation_task.py` so the
|
||||
/// mobile chips use the same wording as the web ToolCallCard status pills.
|
||||
const Map<String, String> _toolLabels = {
|
||||
'create_note': 'Created note',
|
||||
'update_note': 'Updated note',
|
||||
'delete_note': 'Deleted note',
|
||||
'create_task': 'Created task',
|
||||
'update_task': 'Updated task',
|
||||
'delete_task': 'Deleted task',
|
||||
'read_note': 'Read note',
|
||||
'list_notes': 'Listed notes',
|
||||
'list_tasks': 'Searched tasks',
|
||||
'search_notes': 'Searched notes',
|
||||
'create_event': 'Created event',
|
||||
'list_events': 'Searched calendar',
|
||||
'search_events': 'Searched calendar',
|
||||
'update_event': 'Updated event',
|
||||
'delete_event': 'Removed event',
|
||||
'list_calendars': 'Listed calendars',
|
||||
'search_web': 'Searched the web',
|
||||
'research_topic': 'Researched topic',
|
||||
'search_images': 'Searched images',
|
||||
'create_project': 'Created project',
|
||||
'update_project': 'Updated project',
|
||||
'list_projects': 'Listed projects',
|
||||
'get_project': 'Read project',
|
||||
'search_projects': 'Searched projects',
|
||||
'create_milestone': 'Created milestone',
|
||||
'update_milestone': 'Updated milestone',
|
||||
'list_milestones': 'Listed milestones',
|
||||
'set_rag_scope': 'Changed knowledge scope',
|
||||
'calculate': 'Calculated',
|
||||
'read_article': 'Read article',
|
||||
'get_profile': 'Read profile',
|
||||
'update_profile': 'Updated profile',
|
||||
'update_person': 'Updated person',
|
||||
'update_place': 'Updated place',
|
||||
'add_task_log': 'Logged task progress',
|
||||
};
|
||||
|
||||
IconData _iconFor(String fn) {
|
||||
if (fn.contains('note')) return LucideIcons.stickyNote;
|
||||
if (fn.contains('task')) return LucideIcons.checkCircle2;
|
||||
if (fn.contains('event') || fn.contains('calendar')) {
|
||||
return LucideIcons.calendarCheck;
|
||||
}
|
||||
if (fn.contains('project')) return LucideIcons.folder;
|
||||
if (fn.contains('milestone')) return LucideIcons.flag;
|
||||
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
|
||||
return LucideIcons.globe;
|
||||
}
|
||||
if (fn.contains('image')) return LucideIcons.image;
|
||||
if (fn.contains('person') || fn.contains('profile')) {
|
||||
return LucideIcons.user;
|
||||
}
|
||||
if (fn.contains('place')) return LucideIcons.mapPin;
|
||||
if (fn.contains('rag') || fn.contains('scope')) return LucideIcons.sliders;
|
||||
if (fn.contains('calculate')) return LucideIcons.calculator;
|
||||
return LucideIcons.sparkles;
|
||||
}
|
||||
|
||||
/// Pull the destination route for this tool call from its `result` payload,
|
||||
/// if the tool produced something we can navigate to. Returns `null` for
|
||||
/// read-only / no-target tools so the chip stays visible but non-tappable.
|
||||
///
|
||||
/// Backend tool results use the shape:
|
||||
/// `{success, type: "note"|"task"|"event"|"project"|..., data: {id, ...}}`
|
||||
/// which is defined alongside each tool handler (see
|
||||
/// `services/tools/notes.py`, `calendar.py`, etc.).
|
||||
String? _routeForToolCall(Map<String, dynamic> tc) {
|
||||
final result = tc['result'];
|
||||
if (result is! Map<String, dynamic>) return null;
|
||||
if (result['success'] != true) return null;
|
||||
final type = result['type'] as String?;
|
||||
final data = result['data'];
|
||||
if (type == null || data is! Map<String, dynamic>) return null;
|
||||
final id = data['id'];
|
||||
if (id is! int) return null;
|
||||
switch (type) {
|
||||
case 'note':
|
||||
return Routes.noteDetail.replaceFirst(':id', '$id');
|
||||
case 'task':
|
||||
return Routes.taskEdit.replaceFirst(':id', '$id');
|
||||
case 'event':
|
||||
case 'event_updated':
|
||||
// No single-event route on mobile — fall back to the calendar.
|
||||
return Routes.calendar;
|
||||
case 'project':
|
||||
return Routes.projectTasks.replaceFirst(':id', '$id');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Small status pill rendered inside an assistant message bubble for each
|
||||
/// tool invocation. Mirrors the web app's ToolCallCard header at a glance —
|
||||
/// icon + label + success/error tint — and, when the tool produced a
|
||||
/// navigable entity (note, task, event, project), tapping the chip opens it.
|
||||
class ToolCallChip extends StatelessWidget {
|
||||
final Map<String, dynamic> toolCall;
|
||||
const ToolCallChip({super.key, required this.toolCall});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final function = (toolCall['function'] as String?) ?? 'tool';
|
||||
final status = (toolCall['status'] as String?) ?? 'success';
|
||||
final isError = status == 'error';
|
||||
final label = _toolLabels[function] ?? function;
|
||||
|
||||
final route = isError ? null : _routeForToolCall(toolCall);
|
||||
|
||||
final bg = isError
|
||||
? scheme.errorContainer.withValues(alpha: 0.55)
|
||||
: scheme.primary.withValues(alpha: 0.12);
|
||||
final fg = isError ? scheme.onErrorContainer : scheme.primary;
|
||||
|
||||
// Pull the entity title from the result payload so the chip can show
|
||||
// "Created note: Grocery List" instead of a generic label. Falls back
|
||||
// to the generic label when the tool didn't return a titled entity.
|
||||
String? entityTitle;
|
||||
final result = toolCall['result'];
|
||||
if (result is Map<String, dynamic>) {
|
||||
final data = result['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
final t = data['title'];
|
||||
if (t is String && t.isNotEmpty) entityTitle = t;
|
||||
}
|
||||
}
|
||||
final displayText =
|
||||
entityTitle != null ? '$label: $entityTitle' : label;
|
||||
|
||||
final chip = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: fg.withValues(alpha: 0.35), width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_iconFor(function), size: 13, color: fg),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (route != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(LucideIcons.arrowRight, size: 11, color: fg),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (route == null) return chip;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () => context.push(route),
|
||||
child: chip,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../providers/voice_provider.dart';
|
||||
|
||||
/// Animated mic button that reflects the current [VoiceMode].
|
||||
///
|
||||
/// - idle: muted background, mic_none icon
|
||||
/// - recording: red, pulses with live [amplitude] for real-time feedback
|
||||
/// - transcribing: indigo with spinner
|
||||
/// - playing: indigo with volume_up icon
|
||||
class VoiceMicButton extends StatelessWidget {
|
||||
final VoiceMode mode;
|
||||
final bool voiceModeActive;
|
||||
/// Live mic amplitude 0.0–1.0 while recording. Drives the button scale
|
||||
/// and glow so the user has obvious feedback that audio is being picked
|
||||
/// up. Ignored when not in [VoiceMode.recording].
|
||||
final double amplitude;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const VoiceMicButton({
|
||||
super.key,
|
||||
required this.mode,
|
||||
required this.voiceModeActive,
|
||||
this.amplitude = 0.0,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
Color _bgColor(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return switch (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 =
|
||||
mode == VoiceMode.idle ? cs.onSurfaceVariant : Colors.white;
|
||||
|
||||
return switch (mode) {
|
||||
VoiceMode.transcribing => SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
VoiceMode.playing => Icon(LucideIcons.volume2, color: iconColor, size: 20),
|
||||
_ => Icon(LucideIcons.mic, color: iconColor, size: 20),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isRecording = mode == VoiceMode.recording;
|
||||
|
||||
final button = Material(
|
||||
color: _bgColor(context),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(child: _icon(context)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!isRecording) return button;
|
||||
|
||||
// Base pulse so silence still breathes (0.1 floor), scale + glow climb
|
||||
// linearly with live amplitude.
|
||||
final amp = amplitude.clamp(0.0, 1.0);
|
||||
final pulse = 0.1 + amp * 0.9;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFEF4444).withValues(alpha: 0.2 + pulse * 0.3),
|
||||
blurRadius: 6 + pulse * 14,
|
||||
spreadRadius: 1 + pulse * 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AnimatedScale(
|
||||
scale: 1.0 + pulse * 0.18,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
child: button,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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,26 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_timezone/flutter_timezone_plugin.h>
|
||||
#include <open_file_linux/open_file_linux_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_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) record_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
|
||||
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_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,10 +3,15 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_timezone
|
||||
open_file_linux
|
||||
record_linux
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
vad
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -5,14 +5,26 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audio_session
|
||||
import flutter_inappwebview_macos
|
||||
import flutter_timezone
|
||||
import just_audio
|
||||
import open_file_mac
|
||||
import package_info_plus
|
||||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
import sqlite3_flutter_libs
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
+377
-1
@@ -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:
|
||||
@@ -49,6 +57,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "22fdcc3cfeb9d974d7408718c4be15ec5e9b1b350088f3a6c88f154e74dd700d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.14.1"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.5"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +113,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -145,6 +209,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.7"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -169,6 +241,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.32.1"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.32.1"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -201,6 +289,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
|
||||
@@ -352,6 +448,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.2"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -392,6 +496,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -408,6 +520,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:
|
||||
@@ -448,6 +584,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
lucide_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: lucide_icons
|
||||
sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.257.0"
|
||||
markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -601,7 +745,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
@@ -752,6 +896,86 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: recase
|
||||
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
record:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: record
|
||||
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
record_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_android
|
||||
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.1"
|
||||
record_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_ios
|
||||
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
record_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_linux
|
||||
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
record_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_macos
|
||||
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
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:
|
||||
@@ -760,6 +984,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:
|
||||
@@ -848,11 +1080,27 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
simple_gesture_detector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: simple_gesture_detector
|
||||
sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.3"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -877,6 +1125,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.44.3"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -901,6 +1173,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -909,6 +1189,22 @@ 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"
|
||||
table_calendar:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: table_calendar
|
||||
sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -957,6 +1253,86 @@ 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"
|
||||
vad:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vad
|
||||
sha256: ef6c8b12c5af7a6a519ff5684f074b8a2ac00c434705f544af379ea77bccd258
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.7+1"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
lucide_icons: ^0.257.0
|
||||
flutter_riverpod: ^3.3.1
|
||||
go_router: ^17.1.0
|
||||
dio: ^5.6.0
|
||||
@@ -27,12 +28,26 @@ dependencies:
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
google_fonts: ^8.0.2
|
||||
flutter_timezone: ^5.0.2
|
||||
url_launcher: ^6.3.1
|
||||
record: ^6.2.0
|
||||
vad: ^0.0.7
|
||||
just_audio: ^0.9.39
|
||||
table_calendar: ^3.1.2
|
||||
|
||||
# Tier 2 offline mode — local SQL store via Drift (sqlite3 under the hood).
|
||||
# Drift was preferred over Hive in the design since the repo already mirrors
|
||||
# well-defined backend schemas (notes/tasks/projects/etc.) one-to-one.
|
||||
drift: ^2.20.0
|
||||
sqlite3_flutter_libs: ^0.5.24
|
||||
path: ^1.9.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
drift_dev: ^2.20.0
|
||||
build_runner: ^2.4.13
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Publish a signed release APK to Forgejo.
|
||||
#
|
||||
# Finds the release matching $TAG (creating it if the CI job got here
|
||||
# before the UI did), then attaches the APK as a release asset.
|
||||
#
|
||||
# Required env:
|
||||
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
|
||||
# TAG — release tag (e.g. v26.04.11)
|
||||
# APK — path to the built APK
|
||||
#
|
||||
# Optional env:
|
||||
# FORGEJO_API — defaults to the FabledApp repo API root
|
||||
#
|
||||
# Exits non-zero if the release can't be created or the asset upload
|
||||
# fails. Designed to be testable locally:
|
||||
# RELEASE_TOKEN=... TAG=v0.0.1 APK=/tmp/test.apk bash -x scripts/publish_apk_release.sh
|
||||
set -euo pipefail
|
||||
|
||||
: "${RELEASE_TOKEN:?RELEASE_TOKEN not set}"
|
||||
: "${TAG:?TAG not set}"
|
||||
: "${APK:?APK not set}"
|
||||
|
||||
if [ ! -f "$APK" ]; then
|
||||
echo "APK not found at $APK" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
API="${FORGEJO_API:-https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp}"
|
||||
|
||||
# jq isn't in the cirruslabs Flutter image, so we parse JSON with grep.
|
||||
# Fragile but bounded: we only care about the first "id": <n> field,
|
||||
# which is always the release id in Forgejo's responses for these
|
||||
# endpoints. If Forgejo ever adds a preceding id field, revisit.
|
||||
extract_id() {
|
||||
echo "$1" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+'
|
||||
}
|
||||
|
||||
# Look for an existing release (created via the UI or a prior run).
|
||||
# curl -f turns 4xx/5xx into non-zero so we can distinguish "not found"
|
||||
# (no release yet) from "auth broken" (real failure).
|
||||
existing=$(curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
|
||||
"$API/releases/tags/$TAG" 2>/dev/null || true)
|
||||
|
||||
release_id=$(extract_id "$existing")
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "No existing release for $TAG — creating one..."
|
||||
response=$(curl -fsS -X POST \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}" \
|
||||
"$API/releases")
|
||||
release_id=$(extract_id "$response")
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "Failed to create release. API response:" >&2
|
||||
echo "$response" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Created release $TAG (id=$release_id)."
|
||||
else
|
||||
echo "Found existing release $TAG (id=$release_id). Attaching APK..."
|
||||
fi
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-F "attachment=@$APK" \
|
||||
"$API/releases/$release_id/assets" > /dev/null
|
||||
|
||||
echo "Done — $TAG is live at:"
|
||||
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
|
||||
+238
-3
@@ -1,8 +1,243 @@
|
||||
// Placeholder — integration tests go here once the app is running on device.
|
||||
import 'package:fabled_app/data/models/calendar_event.dart';
|
||||
import 'package:fabled_app/data/api/voice_api.dart';
|
||||
import 'package:fabled_app/data/repositories/write_queue.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'));
|
||||
});
|
||||
});
|
||||
|
||||
group('CalendarEvent.fromJson', () {
|
||||
test('parses all fields', () {
|
||||
final json = {
|
||||
'id': 10,
|
||||
'title': 'Team meeting',
|
||||
'start_dt': '2026-04-07T09:00:00+00:00',
|
||||
'end_dt': '2026-04-07T10:00:00+00:00',
|
||||
'all_day': false,
|
||||
'description': 'Weekly sync',
|
||||
'location': 'Room 4',
|
||||
'color': '#6366F1',
|
||||
'recurrence': 'FREQ=WEEKLY',
|
||||
'project_id': 3,
|
||||
'reminder_minutes': 15,
|
||||
};
|
||||
final event = CalendarEvent.fromJson(json);
|
||||
expect(event.id, equals(10));
|
||||
expect(event.title, equals('Team meeting'));
|
||||
expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00').toLocal()));
|
||||
expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00').toLocal()));
|
||||
expect(event.allDay, isFalse);
|
||||
expect(event.description, equals('Weekly sync'));
|
||||
expect(event.location, equals('Room 4'));
|
||||
expect(event.color, equals('#6366F1'));
|
||||
expect(event.recurrence, equals('FREQ=WEEKLY'));
|
||||
expect(event.projectId, equals(3));
|
||||
expect(event.reminderMinutes, equals(15));
|
||||
});
|
||||
|
||||
test('handles null optional fields', () {
|
||||
final json = {
|
||||
'id': 11,
|
||||
'title': 'Birthday',
|
||||
'start_dt': '2026-05-01T00:00:00+00:00',
|
||||
'end_dt': null,
|
||||
'all_day': true,
|
||||
'description': '',
|
||||
'location': '',
|
||||
'color': '',
|
||||
'recurrence': null,
|
||||
'project_id': null,
|
||||
'reminder_minutes': null,
|
||||
};
|
||||
final event = CalendarEvent.fromJson(json);
|
||||
expect(event.endDt, isNull);
|
||||
expect(event.allDay, isTrue);
|
||||
expect(event.recurrence, isNull);
|
||||
expect(event.projectId, isNull);
|
||||
expect(event.reminderMinutes, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('dateOnly', () {
|
||||
test('strips time from datetime', () {
|
||||
final dt = DateTime(2026, 4, 7, 14, 30, 45);
|
||||
expect(dateOnly(dt), equals(DateTime(2026, 4, 7)));
|
||||
});
|
||||
});
|
||||
|
||||
group('QueueFailure.message', () {
|
||||
test('overwritten with title quotes the title', () {
|
||||
final f = QueueFailure(
|
||||
reason: QueueFailureReason.overwritten,
|
||||
domain: 'notes',
|
||||
title: 'Grocery list',
|
||||
);
|
||||
expect(f.message, contains('"Grocery list"'));
|
||||
expect(f.message, contains('overwritten'));
|
||||
});
|
||||
|
||||
test('rejected without title falls back to generic phrasing', () {
|
||||
final f = QueueFailure(
|
||||
reason: QueueFailureReason.rejected,
|
||||
domain: 'tasks',
|
||||
detail: 'title required',
|
||||
);
|
||||
expect(f.message, contains('an offline edit'));
|
||||
expect(f.message, contains('title required'));
|
||||
});
|
||||
|
||||
test('missing communicates server-side deletion', () {
|
||||
final f = QueueFailure(
|
||||
reason: QueueFailureReason.missing,
|
||||
domain: 'projects',
|
||||
title: 'Q2 launch',
|
||||
);
|
||||
expect(f.message, contains('"Q2 launch"'));
|
||||
expect(f.message, contains('deleted on the server'));
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -7,8 +7,23 @@
|
||||
#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 <record_windows/record_windows_plugin_c_api.h>
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_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"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_inappwebview_windows
|
||||
flutter_timezone
|
||||
permission_handler_windows
|
||||
record_windows
|
||||
sqlite3_flutter_libs
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
vad
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
Reference in New Issue
Block a user