Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ba889a38be | |||
| 7fce19a37c | |||
| 9f524e158d | |||
| 46d0427901 | |||
| 45294ade31 | |||
| 89c31f1904 | |||
| 9f1d2317af | |||
| c3d9cc273f | |||
| 63e01389e8 | |||
| 5ca5856f15 | |||
| a337c3fda3 | |||
| fc1c7cade2 | |||
| 0971433c7a | |||
| 844f68d376 | |||
| cab8d7104f | |||
| baba5c3462 | |||
| 97c049e453 | |||
| a19af3388d | |||
| bae6597ec2 | |||
| bc48a49de8 | |||
| a687e3637f | |||
| c8becd6afd | |||
| e63370bf0a | |||
| f39b8ddb30 | |||
| def7519feb | |||
| e86d1a59af | |||
| c8f3861eb5 | |||
| a2e734c498 | |||
| 3a336ddf88 | |||
| 3a8cc0db23 | |||
| fa1b65484c | |||
| 4b6fca39a8 | |||
| 8a31034621 | |||
| 6232c7c99a | |||
| 3422caebfc | |||
| 3bd9c64477 | |||
| ef4872e24a | |||
| 6af23fc853 | |||
| 0999774f34 | |||
| 46425a4b27 | |||
| 86244cdfbc | |||
| 6abc4257be | |||
| 04b7e1cc8a |
@@ -0,0 +1,116 @@
|
||||
# CI runs first; build only proceeds if all checks pass.
|
||||
#
|
||||
# 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.
|
||||
# The tag push triggers this workflow; the build job attaches the APK.
|
||||
#
|
||||
# Note: JavaScript actions (actions/checkout, actions/upload-artifact) cannot run
|
||||
# inside the Flutter container because it has no Node.js. All steps use shell
|
||||
# commands directly instead.
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# 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: ci-runner
|
||||
container:
|
||||
# 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: |
|
||||
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
|
||||
git checkout "$GITHUB_SHA"
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Test
|
||||
run: flutter test
|
||||
|
||||
build:
|
||||
name: Build release APK
|
||||
needs: [analyze]
|
||||
# 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:3.41.6
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
|
||||
git checkout "$GITHUB_SHA"
|
||||
|
||||
- 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)
|
||||
TAG="${{ github.ref_name }}"
|
||||
BUILD_NAME="${TAG#v}"
|
||||
BUILD_NUMBER=$(echo "$BUILD_NAME" | tr -d '.')
|
||||
flutter build apk --release \
|
||||
--build-name="$BUILD_NAME" \
|
||||
--build-number="$BUILD_NUMBER"
|
||||
|
||||
- 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 }}
|
||||
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/
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
# Fabled — Android App
|
||||
|
||||
Native Android client for [FabledAssistant](https://github.com/yourusername/fabledassistant), a self-hosted AI productivity assistant.
|
||||
Native Android client for FabledAssistant, a self-hosted AI second-brain and productivity assistant.
|
||||
|
||||
## Features
|
||||
|
||||
- **Notes** — create, edit, and browse markdown notes
|
||||
- **Tasks** — manage tasks with status (To Do / In Progress / Done) and priority
|
||||
- **Chat** — streaming AI conversations with real-time SSE response display
|
||||
- **Quick Capture** — FAB shortcut to create a note or task from anywhere
|
||||
- **OAuth / SSO** — authenticates via your server's configured OIDC provider; local username/password login also supported if enabled on the server
|
||||
- **Session persistence** — stays logged in across app restarts via a persistent cookie jar
|
||||
- **Home screen widget** — tap to open the chat screen directly from the Android launcher
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
- A running [FabledAssistant](https://github.com/yourusername/fabledassistant) server (self-hosted)
|
||||
- A running FabledAssistant server (self-hosted)
|
||||
- Android 5.0+ (API 21)
|
||||
|
||||
## Getting Started
|
||||
@@ -38,20 +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 + shell nav
|
||||
├── 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
|
||||
│ ├── 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)
|
||||
screens/ # Flutter UI screens
|
||||
│ ├── 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
|
||||
│ ├── 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 (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`, `flutter_inappwebview`, `flutter_markdown`
|
||||
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`, `table_calendar`, `record`, `just_audio`
|
||||
|
||||
## Building a Release APK
|
||||
|
||||
@@ -59,4 +95,25 @@ lib/
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
The signed APK will be at `build/app/outputs/flutter-apk/app-release.apk`.
|
||||
The APK will be at `build/app/outputs/flutter-apk/app-release.apk`.
|
||||
|
||||
## CI / CD
|
||||
|
||||
CI and build are defined in a single workflow (`.forgejo/workflows/ci.yml`) running on the shared `py3.12-node22` act runner with the `ghcr.io/cirruslabs/flutter:stable` container.
|
||||
|
||||
| Trigger | Analyze + Test | Build APK | Release |
|
||||
|---------|---------------|-----------|---------|
|
||||
| PR | ✓ | — | — |
|
||||
| Push `dev` | ✓ | ✓ (artifact `fabledapp-dev-<sha>`) | — |
|
||||
| Push `main` | ✓ | — | — |
|
||||
| Tag `v*` | ✓ | ✓ (artifact `fabledapp-<sha>`) | ✓ Forgejo Release + APK attached |
|
||||
|
||||
The build job has `needs: [analyze]` — a failed analyze or test blocks the APK build.
|
||||
|
||||
To cut a release:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
@@ -23,6 +23,9 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
# ?'key': value applies ? to the key (never null for string literals);
|
||||
# the if (x != null) form is correct for nullable-value map entries.
|
||||
use_null_aware_elements: false
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -10,3 +10,4 @@ GeneratedPluginRegistrant.java
|
||||
# Signing secrets — never commit these
|
||||
key.properties
|
||||
fabled-release-key.jks
|
||||
app/release.jks
|
||||
|
||||
@@ -37,11 +37,13 @@ android {
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ android {
|
||||
val variant = this
|
||||
outputs.all {
|
||||
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
output?.outputFileName = "Fabled-${variant.versionName}.${variant.versionCode}.apk"
|
||||
output?.outputFileName = "Fabled-${variant.versionCode}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
@@ -70,5 +71,14 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- url_launcher: open http/https links in browser -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
# Fabled App Overhaul — Plan 1: Foundation (Theme + Capture Queue)
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Apply the main app's slate-indigo palette and Fraunces typography to the Flutter app, and replace the blocking quick-capture input with a multi-item sequential work queue.
|
||||
|
||||
**Architecture:** A new `lib/core/theme.dart` defines both light and dark `ThemeData` with custom `ColorScheme` and Fraunces headings via `google_fonts`. The capture bar in `app.dart` delegates to a new `CaptureWorkQueueNotifier` (in-memory queue, drains sequentially) instead of blocking on each request. A `_captureResultProvider` carries per-item outcomes to the bar for snackbar display.
|
||||
|
||||
**Tech Stack:** Flutter/Dart, Riverpod, google_fonts package.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
**Create:**
|
||||
- `lib/core/theme.dart` — light + dark `ThemeData`, `GradientButton` widget
|
||||
- `lib/providers/capture_work_queue_provider.dart` — in-memory work queue notifier + result provider
|
||||
|
||||
**Modify:**
|
||||
- `pubspec.yaml` — add `google_fonts: ^6.2.1`
|
||||
- `lib/main.dart` — import and use `fabledTheme` / `fabledDarkTheme`
|
||||
- `lib/app.dart` — update `_QuickCaptureBar` to use work queue
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Theme
|
||||
|
||||
### Task 1: Add google_fonts dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `pubspec.yaml`
|
||||
|
||||
- [ ] **Step 1: Add dependency**
|
||||
|
||||
In the `dependencies:` section, after `flutter_markdown_plus`, add:
|
||||
```yaml
|
||||
google_fonts: ^6.2.1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it resolves**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
Expected: exits 0, no version conflicts.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add pubspec.yaml pubspec.lock
|
||||
git commit -m "feat: add google_fonts dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create theme.dart
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/core/theme.dart`
|
||||
|
||||
- [ ] **Step 1: Create `lib/core/theme.dart`**
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const _darkBackground = Color(0xFF111113);
|
||||
const _darkSurface = Color(0xFF18181C);
|
||||
const _darkSurfaceVar = Color(0xFF1E1E24);
|
||||
const _darkPrimary = Color(0xFF6366F1);
|
||||
const _darkOnSurface = Color(0xFFE8E8F0);
|
||||
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
||||
const _darkOutline = Color(0xFF2E2E3A);
|
||||
|
||||
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);
|
||||
|
||||
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||
|
||||
TextTheme _buildTextTheme(TextTheme base) {
|
||||
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||
return base.copyWith(
|
||||
// Headings / titles use Fraunces
|
||||
headlineLarge: fraunces.headlineLarge,
|
||||
headlineMedium: fraunces.headlineMedium,
|
||||
headlineSmall: fraunces.headlineSmall,
|
||||
titleLarge: fraunces.titleLarge,
|
||||
titleMedium: fraunces.titleMedium,
|
||||
// Body / labels remain system default
|
||||
);
|
||||
}
|
||||
|
||||
// ── Themes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ThemeData fabledDarkTheme() {
|
||||
final cs = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: _darkPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFF3730A3),
|
||||
onPrimaryContainer: _darkOnSurface,
|
||||
secondary: _darkPrimary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: _darkSurfaceVar,
|
||||
onSecondaryContainer: _darkOnSurface,
|
||||
tertiary: _darkPrimary,
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _darkSurfaceVar,
|
||||
onTertiaryContainer: _darkOnSurface,
|
||||
error: const Color(0xFFEF4444),
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFF7F1D1D),
|
||||
onErrorContainer: const Color(0xFFFEE2E2),
|
||||
surface: _darkSurface,
|
||||
onSurface: _darkOnSurface,
|
||||
surfaceContainerHighest: _darkSurfaceVar,
|
||||
onSurfaceVariant: _darkOnSurfaceVar,
|
||||
outline: _darkOutline,
|
||||
outlineVariant: _darkOutline,
|
||||
shadow: Colors.black,
|
||||
scrim: Colors.black,
|
||||
inverseSurface: _darkOnSurface,
|
||||
onInverseSurface: _darkSurface,
|
||||
inversePrimary: _lightPrimary,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _darkBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||
cardTheme: CardTheme(
|
||||
color: _darkSurface,
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: _darkSurface,
|
||||
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: _darkSurface,
|
||||
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: _darkSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _darkSurfaceVar,
|
||||
labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12),
|
||||
side: BorderSide(color: _darkOutline),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData fabledLightTheme() {
|
||||
final cs = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: _lightPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFFE0E0FF),
|
||||
onPrimaryContainer: _lightOnSurface,
|
||||
secondary: _lightPrimary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: _lightSurfaceVar,
|
||||
onSecondaryContainer: _lightOnSurface,
|
||||
tertiary: _lightPrimary,
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _lightSurfaceVar,
|
||||
onTertiaryContainer: _lightOnSurface,
|
||||
error: const Color(0xFFDC2626),
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFFFEE2E2),
|
||||
onErrorContainer: const Color(0xFF7F1D1D),
|
||||
surface: _lightSurface,
|
||||
onSurface: _lightOnSurface,
|
||||
surfaceContainerHighest: _lightSurfaceVar,
|
||||
onSurfaceVariant: _lightOnSurfaceVar,
|
||||
outline: _lightOutline,
|
||||
outlineVariant: _lightOutline,
|
||||
shadow: Colors.black,
|
||||
scrim: Colors.black,
|
||||
inverseSurface: _lightOnSurface,
|
||||
onInverseSurface: _lightSurface,
|
||||
inversePrimary: _darkPrimary,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _lightBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||
cardTheme: CardTheme(
|
||||
color: _lightSurface,
|
||||
elevation: 1,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: _lightSurface,
|
||||
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: _lightSurface,
|
||||
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: _lightSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _lightSurfaceVar,
|
||||
labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12),
|
||||
side: BorderSide(color: _lightOutline),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
||||
|
||||
class GradientButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const GradientButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = onPressed == null;
|
||||
return AnimatedOpacity(
|
||||
opacity: disabled ? 0.45 : 1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: disabled
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
|
||||
),
|
||||
color: disabled ? const Color(0xFF6366F1) : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: disabled
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(padding: padding, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
```bash
|
||||
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
|
||||
flutter analyze lib/core/theme.dart
|
||||
```
|
||||
|
||||
Expected: no errors (warnings about deprecated APIs are OK).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/core/theme.dart
|
||||
git commit -m "feat: custom slate-indigo theme with Fraunces typography"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire theme into the app
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/main.dart` (import theme)
|
||||
- Modify: `lib/app.dart` (FabledApp widget uses new themes)
|
||||
|
||||
- [ ] **Step 1: Read `lib/app.dart` lines 498–523 (the `FabledApp` widget)**
|
||||
|
||||
Locate the `FabledApp.build()` method. It currently has:
|
||||
```dart
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace with custom themes**
|
||||
|
||||
Add import at the top of `lib/app.dart`:
|
||||
```dart
|
||||
import 'core/theme.dart';
|
||||
```
|
||||
|
||||
Replace the `theme:` and `darkTheme:` arguments:
|
||||
```dart
|
||||
theme: fabledLightTheme(),
|
||||
darkTheme: fabledDarkTheme(),
|
||||
```
|
||||
|
||||
Remove the now-unused `import 'package:flutter/material.dart'` reference to `Colors.indigo` (keep the `material.dart` import itself).
|
||||
|
||||
- [ ] **Step 3: Run the app and visually verify**
|
||||
|
||||
```bash
|
||||
flutter run --debug
|
||||
```
|
||||
|
||||
Expected: app launches with dark slate-indigo background, indigo navigation bar, Fraunces headings visible on any screen that uses `titleLarge` or `headlineMedium`. No runtime errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/app.dart
|
||||
git commit -m "feat: wire fabledLightTheme/fabledDarkTheme into MaterialApp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Capture Work Queue
|
||||
|
||||
### Task 4: Create CaptureWorkQueueNotifier
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/providers/capture_work_queue_provider.dart`
|
||||
|
||||
This provider manages an in-memory FIFO queue of capture texts. A single async drain loop processes them sequentially. On success it publishes a result via `captureResultProvider` so the UI can show a snackbar. On `NetworkException` it falls through to the offline `captureQueueProvider`.
|
||||
|
||||
- [ ] **Step 1: Create `lib/providers/capture_work_queue_provider.dart`**
|
||||
|
||||
```dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/exceptions.dart';
|
||||
import '../data/api/quick_capture_api.dart';
|
||||
import 'api_client_provider.dart';
|
||||
import 'capture_queue_provider.dart';
|
||||
import 'notes_provider.dart';
|
||||
import 'tasks_provider.dart';
|
||||
|
||||
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
|
||||
class CaptureResult {
|
||||
final String message;
|
||||
final bool isError;
|
||||
const CaptureResult(this.message, {this.isError = false});
|
||||
}
|
||||
|
||||
/// The most recent capture result. UI watches this to show snackbars.
|
||||
/// Reset to null by the notifier before each new item so listeners always fire.
|
||||
final captureResultProvider = StateProvider<CaptureResult?>((_) => null);
|
||||
|
||||
/// In-memory sequential work queue for quick captures.
|
||||
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
|
||||
final captureWorkQueueProvider =
|
||||
StateNotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||
(ref) => CaptureWorkQueueNotifier(ref),
|
||||
);
|
||||
|
||||
class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
|
||||
final Ref _ref;
|
||||
bool _running = false;
|
||||
|
||||
CaptureWorkQueueNotifier(this._ref) : super([]);
|
||||
|
||||
/// Add text to the queue and start the drain loop if not already running.
|
||||
void enqueue(String text) {
|
||||
state = [...state, text];
|
||||
_drain();
|
||||
}
|
||||
|
||||
Future<void> _drain() async {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
try {
|
||||
while (state.isNotEmpty) {
|
||||
final text = state.first;
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
} 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(
|
||||
"You're offline — capture saved and will retry automatically.",
|
||||
isError: false,
|
||||
);
|
||||
break;
|
||||
} on AppException catch (e) {
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
_ref.read(captureResultProvider.notifier).state =
|
||||
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);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'note' => 'Note',
|
||||
'task' => 'Task',
|
||||
'event' => 'Event',
|
||||
'todo' => 'To-do',
|
||||
_ => type,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Analyze for errors**
|
||||
|
||||
```bash
|
||||
flutter analyze lib/providers/capture_work_queue_provider.dart
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/providers/capture_work_queue_provider.dart
|
||||
git commit -m "feat: CaptureWorkQueueNotifier — sequential multi-item capture queue"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update _QuickCaptureBar to use the work queue
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/app.dart` — `_QuickCaptureBarState`
|
||||
|
||||
The bar currently calls `ref.read(quickCaptureApiProvider).capture(text)` directly and sets `_busy`. Replace with: enqueue to work queue, watch queue depth for badge, watch `captureResultProvider` for snackbars, show progress bar when queue is non-empty.
|
||||
|
||||
- [ ] **Step 1: Add imports to `lib/app.dart`**
|
||||
|
||||
Add at the top alongside existing imports:
|
||||
```dart
|
||||
import 'providers/capture_work_queue_provider.dart';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `_QuickCaptureBarState` completely**
|
||||
|
||||
Replace the entire `_QuickCaptureBarState` class (from `class _QuickCaptureBarState` through its closing `}`) with:
|
||||
|
||||
```dart
|
||||
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
setState(() {}); // clear suffix icon
|
||||
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
|
||||
}
|
||||
|
||||
Future<void> _drainOfflineQueue() async {
|
||||
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);
|
||||
if (!mounted) break;
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
ref.invalidate(projectsProvider);
|
||||
ref.invalidate(projectMilestonesProvider);
|
||||
}
|
||||
} on NetworkException {
|
||||
break;
|
||||
} catch (_) {
|
||||
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _hintForLocation(String location) {
|
||||
if (location.startsWith(Routes.tasks)) return 'Add a task…';
|
||||
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
||||
return 'Capture a note…';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final offlineQueueCount = ref.watch(captureQueueProvider).length;
|
||||
final workQueue = ref.watch(captureWorkQueueProvider);
|
||||
final isWorking = workQueue.isNotEmpty;
|
||||
final totalPending = workQueue.length + offlineQueueCount;
|
||||
|
||||
// Show snackbar when a result is published.
|
||||
ref.listen(captureResultProvider, (_, result) {
|
||||
if (result == null || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: _hintForLocation(location),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 10),
|
||||
prefixIcon: totalPending > 0
|
||||
? Badge(
|
||||
label: Text('$totalPending'),
|
||||
child: const Icon(Icons.cloud_upload_outlined),
|
||||
)
|
||||
: isWorking
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
suffixIcon: _controller.text.trim().isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _submit,
|
||||
tooltip: 'Capture',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push(Routes.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Thin progress bar while the work queue is draining.
|
||||
if (isWorking)
|
||||
const LinearProgressIndicator(minHeight: 2)
|
||||
else
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove now-unused `_busy` field and old `_submit`/`_send` methods**
|
||||
|
||||
They are fully replaced by the new class above. Verify there are no remaining references to `_busy` or the old `_send` method in `_QuickCaptureBarState`.
|
||||
|
||||
- [ ] **Step 4: Analyze**
|
||||
|
||||
```bash
|
||||
flutter analyze lib/app.dart
|
||||
```
|
||||
|
||||
Expected: no errors. Fix any missing imports surfaced by the analyzer.
|
||||
|
||||
- [ ] **Step 5: Run and test manually**
|
||||
|
||||
```bash
|
||||
flutter run --debug
|
||||
```
|
||||
|
||||
Test:
|
||||
1. Type a capture and submit — input clears immediately, progress bar appears briefly, snackbar shows on completion
|
||||
2. Type and submit 3 captures rapidly — all 3 appear in the queue badge, drain one by one, 3 snackbars appear in sequence
|
||||
3. The input field is never disabled during processing
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/app.dart
|
||||
git commit -m "feat: multi-item capture work queue with sequential drain and progress indicator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `flutter analyze` — zero errors
|
||||
- [ ] App launches with dark slate-indigo background on a device/emulator in dark mode
|
||||
- [ ] App launches with light theme when device is set to light mode
|
||||
- [ ] Fraunces font visible in screen titles (e.g. Notes AppBar title)
|
||||
- [ ] Capture bar: submit while processing → second item queues, badge shows count
|
||||
- [ ] Capture bar: submit 3 items → all drain sequentially, 3 snackbars
|
||||
- [ ] Offline: capture → "saved and will retry" snackbar, falls to offline queue
|
||||
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
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,253 @@
|
||||
# Fabled App Overhaul — Design Spec
|
||||
|
||||
**Date:** 2026-03-11
|
||||
**Project:** `/home/bvandeusen/Nextcloud/Projects/fabled_app`
|
||||
|
||||
## Goal
|
||||
|
||||
Reposition the Flutter Android app from a general-purpose mirror of the web app into a focused mobile companion: the Daily Briefing is the primary experience, Quick Capture is the secondary utility, and Notes/Tasks/Projects are a browsable library — secondary to both.
|
||||
|
||||
---
|
||||
|
||||
## Navigation & Shell
|
||||
|
||||
Three-tab shell replacing the current four-tab (Notes · Tasks · Projects · Chat) structure.
|
||||
|
||||
| Tab | Icon | Screen | Route |
|
||||
|-----|------|--------|-------|
|
||||
| Briefing | `Icons.wb_sunny_outlined` / `Icons.wb_sunny` | `BriefingScreen` | `/briefing` |
|
||||
| Library | `Icons.library_books_outlined` / `Icons.library_books` | `LibraryScreen` | `/library` |
|
||||
| Chat | `Icons.chat_bubble_outline` / `Icons.chat_bubble` | `ConversationsListScreen` (inline) | `/conversations` |
|
||||
|
||||
The `_QuickCaptureBar` remains pinned above the shell on all three tabs. The settings icon stays in the capture bar row.
|
||||
|
||||
**Briefing is the initial route** — the app opens directly to the briefing tab on every launch.
|
||||
|
||||
**Wide layout (≥ 600dp):** `NavigationRail` on the left (as today), same 3 destinations.
|
||||
|
||||
### Dead Code Removed
|
||||
|
||||
**Screens deleted:**
|
||||
- `lib/screens/notes/notes_list_screen.dart`
|
||||
- `lib/screens/tasks/tasks_list_screen.dart`
|
||||
- `lib/screens/projects/project_list_screen.dart`
|
||||
- `lib/screens/chat/conversations_list_screen.dart`
|
||||
- `lib/screens/quick_capture/quick_capture_screen.dart`
|
||||
|
||||
**Screens kept:**
|
||||
- `lib/screens/notes/note_detail_screen.dart`
|
||||
- `lib/screens/notes/note_edit_screen.dart`
|
||||
- `lib/screens/tasks/task_edit_screen.dart`
|
||||
- `lib/screens/chat/chat_screen.dart`
|
||||
- All auth, settings, setup, splash screens
|
||||
|
||||
---
|
||||
|
||||
## Theme
|
||||
|
||||
Custom `ColorScheme` matching the main web app's "Illuminated Transcript" palette, in `lib/core/theme.dart`. System light/dark preference respected.
|
||||
|
||||
### Dark theme
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| `background` | `#111113` |
|
||||
| `surface` | `#18181c` |
|
||||
| `primary` | `#6366f1` |
|
||||
| `onSurface` | `#e8e8f0` |
|
||||
| `onSurfaceVariant` (muted) | `#8888a8` |
|
||||
|
||||
### Light theme
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| `background` | `#f4f4f8` |
|
||||
| `surface` | `#ffffff` |
|
||||
| `primary` | `#4f46e5` |
|
||||
| `onSurface` | `#18181c` |
|
||||
| `onSurfaceVariant` (muted) | `#6b6b88` |
|
||||
|
||||
### Typography
|
||||
- Add `google_fonts` to `pubspec.yaml`
|
||||
- `headlineMedium`, `titleLarge`, `titleMedium` → `GoogleFonts.fraunces()`
|
||||
- Body styles → system default (unchanged)
|
||||
|
||||
### Buttons & Cards
|
||||
- Primary action buttons: `BoxDecoration` with `LinearGradient(135°, #6366f1, #4f46e5)`; applied to send buttons in capture bar and briefing reply bar
|
||||
- Cards: `borderRadius: 14`, subtle elevation shadow, no explicit border
|
||||
|
||||
---
|
||||
|
||||
## BriefingScreen
|
||||
|
||||
**File:** `lib/screens/briefing/briefing_screen.dart`
|
||||
|
||||
The app's primary screen. Opens on launch.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
AppBar:
|
||||
title: "Briefing" (Fraunces)
|
||||
subtitle: today's date ("Wednesday, March 11")
|
||||
actions: [↻ refresh button]
|
||||
|
||||
Body (scrollable column):
|
||||
┌─ DigestCard ──────────────────────┐
|
||||
│ ☀ Good morning — Mar 11 │
|
||||
│ [first assistant message, │
|
||||
│ truncated to 5 lines] │
|
||||
│ [Show more ↓] │
|
||||
└───────────────────────────────────┘
|
||||
|
||||
─── Conversation ───
|
||||
|
||||
[scrollable message list]
|
||||
[streaming bubble while generating]
|
||||
|
||||
Bottom pinned:
|
||||
[ Reply to your briefing… ] [➤]
|
||||
```
|
||||
|
||||
### Digest Card
|
||||
- Extracted widget: `lib/widgets/briefing_digest_card.dart`
|
||||
- Shows the content of the first `assistant` message from today's conversation
|
||||
- Truncated to 5 lines by default; `[Show more]` expands with `AnimatedSize`
|
||||
- If no briefing exists yet: "No briefing yet today" placeholder + "Generate now" button
|
||||
|
||||
### Conversation
|
||||
- Message bubbles reuse the same widget used in `ChatScreen`; extracted to `lib/widgets/chat_message_bubble.dart` (shared between both screens)
|
||||
- User messages: right-aligned, primary colour container
|
||||
- Assistant messages: left-aligned, surface container with left accent border (indigo, 2dp)
|
||||
- Streaming: a streaming bubble appears at the bottom of the list while SSE is active
|
||||
|
||||
### Reply Bar
|
||||
- Always visible (pinned to bottom, above system nav bar)
|
||||
- Send button: indigo gradient, disabled when input is empty or streaming
|
||||
- Submitting a reply uses the existing SSE chat endpoint with today's briefing `conversation_id`
|
||||
|
||||
### Overflow Menu (`···`)
|
||||
- "View past briefings" → pushes `BriefingHistoryScreen` (simple date list, read-only — no reply bar)
|
||||
|
||||
### Refresh Button
|
||||
- Calls `POST /api/briefing/trigger` with `{"slot": "compilation"}`
|
||||
- Shows `CircularProgressIndicator` in the AppBar while in-flight
|
||||
- Reloads conversation on completion
|
||||
|
||||
### New Files
|
||||
- `lib/screens/briefing/briefing_screen.dart`
|
||||
- `lib/screens/briefing/briefing_history_screen.dart`
|
||||
- `lib/widgets/briefing_digest_card.dart`
|
||||
- `lib/widgets/chat_message_bubble.dart` (extracted from ChatScreen)
|
||||
- `lib/data/api/briefing_api.dart` — `getToday()`, `getMessages(id)`, `triggerSlot(slot)`
|
||||
- `lib/data/models/briefing_conversation.dart` — `id`, `briefingDate`, `title`, `messages`
|
||||
- `lib/providers/briefing_provider.dart` — `briefingTodayProvider` (AsyncNotifier)
|
||||
|
||||
---
|
||||
|
||||
## LibraryScreen
|
||||
|
||||
**File:** `lib/screens/library/library_screen.dart`
|
||||
|
||||
Unified browsing screen for notes, tasks, and projects.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
AppBar:
|
||||
title: "Library"
|
||||
actions: [🔍 search icon → expands inline search bar]
|
||||
|
||||
Filter pills (scrollable horizontal row):
|
||||
[All] [Notes] [Tasks] [Projects]
|
||||
|
||||
Content:
|
||||
Unified list sorted by updated_at desc
|
||||
Each item: LibraryItemCard
|
||||
|
||||
FAB:
|
||||
[+] → bottom sheet: "New note" | "New task"
|
||||
```
|
||||
|
||||
### Filter Pills
|
||||
| Pill | Content | Extra controls |
|
||||
|------|---------|----------------|
|
||||
| All | Notes + tasks interleaved | — |
|
||||
| Notes | Notes only | — |
|
||||
| Tasks | Tasks only | Secondary status row: Todo · In Progress · Done · All |
|
||||
| Projects | Project cards | — |
|
||||
|
||||
### Item Cards (`lib/widgets/library_item_card.dart`)
|
||||
- **Note:** title (Fraunces medium), 1-line body snippet, tag chips, relative timestamp
|
||||
- **Task:** status checkbox (tappable → cycles `todo → in_progress → done` via `PATCH /api/tasks/:id/status`), title, due date, priority dot (high = red, medium = amber)
|
||||
- **Project:** left colour strip matching project colour, title, active task count, milestone progress bar
|
||||
|
||||
### Search
|
||||
- Tapping 🔍 slides an `AnimatedContainer` search bar into the AppBar
|
||||
- Searches title + body across notes and tasks via `GET /api/notes?q=` (respects active filter pill)
|
||||
- Dismisses with Escape / back gesture
|
||||
|
||||
### FAB
|
||||
- Bottom sheet with two large tap targets: "New note" → `NoteEditScreen`, "New task" → `TaskEditScreen`
|
||||
|
||||
### New Files
|
||||
- `lib/screens/library/library_screen.dart`
|
||||
- `lib/widgets/library_item_card.dart`
|
||||
|
||||
---
|
||||
|
||||
## Quick Capture Queue
|
||||
|
||||
The `_QuickCaptureBar` in `app.dart` is updated to support multiple in-flight captures queued sequentially.
|
||||
|
||||
### Behaviour
|
||||
- Input **never disables** while the worker is active (only disables on offline — existing behaviour)
|
||||
- Submitting appends text to the work queue; the worker drains it one item at a time
|
||||
- Each completion fires a snackbar (`"Note created: …"`)
|
||||
- Queue depth > 0: prefix icon shows a badge `⋯ N`
|
||||
- Worker active: a 2dp `LinearProgressIndicator` in indigo animates below the capture bar
|
||||
|
||||
### Implementation
|
||||
- New `lib/providers/capture_work_queue_provider.dart` — `CaptureWorkQueueNotifier` (in-memory `List<String>` + async drain loop)
|
||||
- Separate from the existing `captureQueueProvider` (which handles offline persistence); the two queues are distinct:
|
||||
- **Work queue:** in-memory, drains sequentially while online
|
||||
- **Offline queue:** persisted to SharedPreferences, drained on reconnect
|
||||
- Network errors during work queue drain fall through to offline queue (existing logic)
|
||||
|
||||
---
|
||||
|
||||
## Files Created / Modified Summary
|
||||
|
||||
**New:**
|
||||
- `lib/core/theme.dart`
|
||||
- `lib/screens/briefing/briefing_screen.dart`
|
||||
- `lib/screens/briefing/briefing_history_screen.dart`
|
||||
- `lib/screens/library/library_screen.dart`
|
||||
- `lib/widgets/briefing_digest_card.dart`
|
||||
- `lib/widgets/chat_message_bubble.dart`
|
||||
- `lib/widgets/library_item_card.dart`
|
||||
- `lib/data/api/briefing_api.dart`
|
||||
- `lib/data/models/briefing_conversation.dart`
|
||||
- `lib/providers/briefing_provider.dart`
|
||||
- `lib/providers/capture_work_queue_provider.dart`
|
||||
|
||||
**Modified:**
|
||||
- `lib/app.dart` — new 3-tab shell, new routes, updated `_QuickCaptureBar`
|
||||
- `lib/main.dart` — import `theme.dart`
|
||||
- `lib/screens/chat/chat_screen.dart` — extract bubble widget
|
||||
- `pubspec.yaml` — add `google_fonts`
|
||||
|
||||
**Deleted:**
|
||||
- `lib/screens/notes/notes_list_screen.dart`
|
||||
- `lib/screens/tasks/tasks_list_screen.dart`
|
||||
- `lib/screens/projects/project_list_screen.dart`
|
||||
- `lib/screens/chat/conversations_list_screen.dart`
|
||||
- `lib/screens/quick_capture/quick_capture_screen.dart`
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- iOS support — Android only, as today
|
||||
- Workspace view — web-only feature
|
||||
- Graph view — web-only feature
|
||||
- Note editing from Library (tap → NoteDetailScreen → edit button, as today)
|
||||
- Push notification handling in-app — handled by the OS notification tray
|
||||
@@ -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)
|
||||
+390
-240
@@ -2,29 +2,41 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
|
||||
import 'core/constants.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'core/theme.dart';
|
||||
import 'providers/api_client_provider.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'providers/capture_queue_provider.dart';
|
||||
import 'providers/milestones_provider.dart';
|
||||
import 'providers/notes_provider.dart';
|
||||
import 'providers/projects_provider.dart';
|
||||
import 'providers/capture_work_queue_provider.dart';
|
||||
import 'providers/briefing_provider.dart';
|
||||
import 'providers/calendar_provider.dart';
|
||||
import 'providers/chat_provider.dart';
|
||||
import 'providers/knowledge_provider.dart';
|
||||
import 'providers/news_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/update_provider.dart';
|
||||
import 'providers/tasks_provider.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/briefing/briefing_screen.dart';
|
||||
import 'screens/knowledge/knowledge_screen.dart';
|
||||
import 'screens/library/project_tasks_screen.dart';
|
||||
import 'screens/chat/chat_screen.dart';
|
||||
import 'screens/chat/conversations_list_screen.dart';
|
||||
import 'screens/chat/conversations_tab_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/notes/notes_list_screen.dart';
|
||||
import 'screens/projects/project_list_screen.dart';
|
||||
import 'screens/settings/settings_screen.dart';
|
||||
import 'screens/news/news_screen.dart';
|
||||
import 'screens/setup/setup_screen.dart';
|
||||
import 'screens/splash/splash_screen.dart';
|
||||
import 'screens/tasks/task_edit_screen.dart';
|
||||
import 'screens/tasks/tasks_list_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
|
||||
@@ -33,6 +45,7 @@ class _RouterNotifier extends ChangeNotifier {
|
||||
_RouterNotifier(Ref ref) {
|
||||
ref.listen(authProvider, (_, _) => notifyListeners());
|
||||
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
|
||||
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,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;
|
||||
@@ -59,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: [
|
||||
@@ -80,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,
|
||||
@@ -108,6 +135,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
taskId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projectTasks,
|
||||
builder: (_, state) => ProjectTasksScreen(
|
||||
projectId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/projects/new',
|
||||
builder: (_, _) => const ProjectEditScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projectEdit,
|
||||
builder: (_, state) => ProjectEditScreen(
|
||||
projectId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.chat,
|
||||
builder: (_, state) => ChatScreen(
|
||||
@@ -118,20 +161,28 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state, child) => _Shell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.notes,
|
||||
builder: (_, _) => const NotesListScreen(),
|
||||
path: Routes.briefing,
|
||||
builder: (_, _) => const BriefingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.tasks,
|
||||
builder: (_, _) => const TasksListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projects,
|
||||
builder: (_, _) => const ProjectListScreen(),
|
||||
path: Routes.knowledge,
|
||||
builder: (_, _) => const KnowledgeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.conversations,
|
||||
builder: (_, _) => const ConversationsListScreen(),
|
||||
builder: (_, _) => const ConversationsTabScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projects,
|
||||
builder: (_, _) => const ProjectsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.news,
|
||||
builder: (_, _) => const NewsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.calendar,
|
||||
builder: (_, _) => const CalendarScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -147,78 +198,154 @@ class _Shell extends ConsumerStatefulWidget {
|
||||
ConsumerState<_Shell> createState() => _ShellState();
|
||||
}
|
||||
|
||||
class _ShellState extends ConsumerState<_Shell> {
|
||||
static const _tabs = [Routes.notes, Routes.tasks, Routes.projects, Routes.conversations];
|
||||
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
static const _baseTabs = [
|
||||
Routes.briefing,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
Routes.projects,
|
||||
];
|
||||
|
||||
List<String> _tabs(bool rssEnabled) => [
|
||||
..._baseTabs,
|
||||
if (rssEnabled) Routes.news,
|
||||
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();
|
||||
// Silent update check on first app load.
|
||||
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) {
|
||||
ref.read(updateProvider.notifier).check(repoUrl);
|
||||
final status = ref.read(updateProvider).status;
|
||||
if (status == UpdateStatus.idle || status == UpdateStatus.error) {
|
||||
ref.read(updateProvider.notifier).check(repoUrl);
|
||||
}
|
||||
}
|
||||
// Sync device timezone to backend so briefing and chat use local time.
|
||||
_syncTimezone();
|
||||
});
|
||||
}
|
||||
|
||||
int _tabIndex(String location) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) return;
|
||||
final now = DateTime.now();
|
||||
if (_lastResumeRefresh != null &&
|
||||
now.difference(_lastResumeRefresh!) < _resumeCooldown) {
|
||||
return;
|
||||
}
|
||||
_lastResumeRefresh = now;
|
||||
_refreshAll();
|
||||
}
|
||||
|
||||
/// Refresh every major data provider. Safe to call speculatively —
|
||||
/// providers that aren't currently watched are already disposed.
|
||||
void _refreshAll() {
|
||||
ref.read(conversationsProvider.notifier).refresh();
|
||||
ref.read(calendarProvider.notifier).refresh();
|
||||
ref.read(newsProvider.notifier).refresh();
|
||||
ref.read(knowledgeProvider.notifier).refresh();
|
||||
// briefingProvider is an AsyncNotifier family; invalidating is safe
|
||||
// even if no conversation is open — it doesn't cause flicker since
|
||||
// the briefing screen isn't a list view.
|
||||
ref.invalidate(briefingProvider);
|
||||
}
|
||||
|
||||
/// Refresh only the provider backing the given shell tab route.
|
||||
void _refreshTab(String route) {
|
||||
if (route == Routes.briefing) {
|
||||
ref.invalidate(briefingProvider);
|
||||
} else if (route == Routes.knowledge) {
|
||||
ref.read(knowledgeProvider.notifier).refresh();
|
||||
} else if (route == Routes.conversations) {
|
||||
ref.read(conversationsProvider.notifier).refresh();
|
||||
} else if (route == Routes.news) {
|
||||
ref.read(newsProvider.notifier).refresh();
|
||||
} else if (route == Routes.calendar) {
|
||||
ref.read(calendarProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTimezone() async {
|
||||
try {
|
||||
final tzInfo = await FlutterTimezone.getLocalTimezone();
|
||||
await ref.read(settingsApiProvider).syncTimezone(tzInfo.identifier);
|
||||
} catch (_) {
|
||||
// Best-effort — failure is non-critical.
|
||||
}
|
||||
}
|
||||
|
||||
int _tabIndex(String location, 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
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);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Later'),
|
||||
if (ref.read(rssEnabledProvider))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.newspaper_outlined),
|
||||
title: const Text('News'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.push(Routes.news);
|
||||
},
|
||||
),
|
||||
if (!isDownloading)
|
||||
FilledButton(
|
||||
onPressed: () => ref
|
||||
.read(updateProvider.notifier)
|
||||
.downloadAndInstall(),
|
||||
child: const Text('Download & Install'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
ListTile(
|
||||
leading: const Icon(Icons.calendar_month_outlined),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -227,17 +354,25 @@ 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));
|
||||
}
|
||||
});
|
||||
final rssEnabled = ref.watch(rssEnabledProvider);
|
||||
final tabs = _tabs(rssEnabled);
|
||||
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;
|
||||
// Use NavigationRail whenever the screen is wide enough — covers both
|
||||
// phone landscape and tablets in either orientation (600 dp breakpoint).
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
|
||||
if (isWide) {
|
||||
@@ -245,34 +380,46 @@ 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.note_outlined),
|
||||
selectedIcon: Icon(Icons.note),
|
||||
label: Text('Notes'),
|
||||
destinations: [
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.wb_sunny_outlined),
|
||||
selectedIcon: Icon(Icons.wb_sunny),
|
||||
label: Text('Briefing'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.check_box_outlined),
|
||||
selectedIcon: Icon(Icons.check_box),
|
||||
label: Text('Tasks'),
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: Text('Knowledge'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: Text('Chat'),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.folder_outlined),
|
||||
selectedIcon: Icon(Icons.folder),
|
||||
label: Text('Projects'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: Text('Chat'),
|
||||
if (ref.watch(rssEnabledProvider))
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.newspaper_outlined),
|
||||
selectedIcon: Icon(Icons.newspaper),
|
||||
label: Text('News'),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.calendar_month_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_month),
|
||||
label: Text('Calendar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -290,19 +437,47 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
const _QuickCaptureBar(),
|
||||
Expanded(child: child),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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.note), label: 'Notes'),
|
||||
NavigationDestination(icon: Icon(Icons.check_box), label: 'Tasks'),
|
||||
NavigationDestination(icon: Icon(Icons.folder), label: 'Projects'),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble), label: 'Chat'),
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -318,13 +493,11 @@ class _QuickCaptureBar extends ConsumerStatefulWidget {
|
||||
|
||||
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
final _controller = TextEditingController();
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Retry any offline-queued captures from previous sessions.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -333,101 +506,58 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
void _submit() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || _busy) return;
|
||||
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
setState(() => _busy = true);
|
||||
|
||||
// Capture the messenger before the async gap so we can show a snackbar
|
||||
// even if the user has navigated to a deeper view by the time it resolves.
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
try {
|
||||
final result = await ref.read(quickCaptureApiProvider).capture(text);
|
||||
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
ref.invalidate(projectsProvider);
|
||||
ref.invalidate(projectMilestonesProvider);
|
||||
}
|
||||
|
||||
final msg = result.message.isNotEmpty
|
||||
? result.message
|
||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
_drainQueue(); // Silently flush any offline-queued captures.
|
||||
} on NetworkException {
|
||||
await ref.read(captureQueueProvider.notifier).enqueue(text);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"You're offline — capture saved and will retry automatically."),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} on AppException catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
} catch (_) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Capture failed. Please try again.'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
setState(() {}); // clear suffix icon
|
||||
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
|
||||
}
|
||||
|
||||
Future<void> _drainQueue() async {
|
||||
Future<void> _drainOfflineQueue() async {
|
||||
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);
|
||||
if (!mounted) break;
|
||||
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);
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
ref.invalidate(projectsProvider);
|
||||
ref.invalidate(projectMilestonesProvider);
|
||||
}
|
||||
} on NetworkException {
|
||||
break; // Still offline — stop draining.
|
||||
break;
|
||||
} catch (_) {
|
||||
// Server/parse error — remove to avoid infinite retries.
|
||||
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
// Server error — drop from queue to prevent ghost items.
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'note' => 'Note',
|
||||
'task' => 'Task',
|
||||
'event' => 'Event',
|
||||
'todo' => 'To-do',
|
||||
_ => type,
|
||||
};
|
||||
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.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…';
|
||||
}
|
||||
@@ -435,61 +565,90 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final queueCount = ref.watch(captureQueueProvider).length;
|
||||
final offlineQueueCount = ref.watch(captureQueueProvider).length;
|
||||
final workQueue = ref.watch(captureWorkQueueProvider);
|
||||
final isWorking = workQueue.isNotEmpty;
|
||||
final totalPending = workQueue.length + offlineQueueCount;
|
||||
|
||||
// Show snackbar when a result is published.
|
||||
ref.listen(captureResultProvider, (_, result) {
|
||||
if (result == null || !mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: _hintForLocation(location),
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
prefixIcon: _busy
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: queueCount > 0
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _submit(),
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
hintText: ref.watch(voiceProvider).voiceModeActive
|
||||
? 'Listening…'
|
||||
: _hintForLocation(location),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 10),
|
||||
prefixIcon: totalPending > 0
|
||||
? Badge(
|
||||
label: Text('$queueCount'),
|
||||
child:
|
||||
const Icon(Icons.cloud_upload_outlined),
|
||||
label: Text('$totalPending'),
|
||||
child: const Icon(Icons.cloud_upload_outlined),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
suffixIcon: _controller.text.trim().isNotEmpty && !_busy
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _submit,
|
||||
tooltip: 'Capture',
|
||||
)
|
||||
: null,
|
||||
: isWorking
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
suffixIcon: _controller.text.trim().isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: _submit,
|
||||
tooltip: 'Capture',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
VoiceMicButton(
|
||||
mode: ref.watch(voiceProvider).mode,
|
||||
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
|
||||
amplitude: ref.watch(voiceProvider).amplitude,
|
||||
onTap: _toggleCaptureMic,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push(Routes.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => context.push(Routes.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Thin progress bar while the work queue is draining.
|
||||
if (isWorking)
|
||||
const LinearProgressIndicator(minHeight: 2)
|
||||
else
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -506,17 +665,8 @@ class FabledApp extends ConsumerWidget {
|
||||
return MaterialApp.router(
|
||||
title: 'Fabled',
|
||||
themeMode: themeMode,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
theme: fabledLightTheme(),
|
||||
darkTheme: fabledDarkTheme(),
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@ 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 news = '/news';
|
||||
static const calendar = '/calendar';
|
||||
static const projectTasks = '/projects/:id/tasks';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const _darkBackground = Color(0xFF0F0F14);
|
||||
const _darkSurface = Color(0xFF16161F);
|
||||
const _darkSurfaceVar = Color(0xFF1A1A24);
|
||||
const _darkPrimary = Color(0xFF7C3AED);
|
||||
const _darkOnSurface = Color(0xFFE4E4F0);
|
||||
const _darkOnSurfaceVar = Color(0xFF8888A8);
|
||||
const _darkOutline = Color(0xFF2A2A3A);
|
||||
|
||||
const _lightBackground = Color(0xFFF5F5FB);
|
||||
const _lightSurface = Color(0xFFFFFFFF);
|
||||
const _lightSurfaceVar = Color(0xFFF0F0F8);
|
||||
const _lightPrimary = Color(0xFF7C3AED);
|
||||
const _lightOnSurface = Color(0xFF1A1A1A);
|
||||
const _lightOnSurfaceVar = Color(0xFF666666);
|
||||
const _lightOutline = Color(0xFFDDDDE8);
|
||||
|
||||
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||
|
||||
TextTheme _buildTextTheme(TextTheme base) {
|
||||
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||
return base.copyWith(
|
||||
// Headings / titles use Fraunces
|
||||
headlineLarge: fraunces.headlineLarge,
|
||||
headlineMedium: fraunces.headlineMedium,
|
||||
headlineSmall: fraunces.headlineSmall,
|
||||
titleLarge: fraunces.titleLarge,
|
||||
titleMedium: fraunces.titleMedium,
|
||||
// Body / labels remain system default
|
||||
);
|
||||
}
|
||||
|
||||
// ── Themes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ThemeData fabledDarkTheme() {
|
||||
final cs = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: _darkPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFF5B21B6),
|
||||
onPrimaryContainer: _darkOnSurface,
|
||||
secondary: _darkPrimary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: _darkSurfaceVar,
|
||||
onSecondaryContainer: _darkOnSurface,
|
||||
tertiary: _darkPrimary,
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _darkSurfaceVar,
|
||||
onTertiaryContainer: _darkOnSurface,
|
||||
error: const Color(0xFFEF4444),
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFF7F1D1D),
|
||||
onErrorContainer: const Color(0xFFFEE2E2),
|
||||
surface: _darkSurface,
|
||||
onSurface: _darkOnSurface,
|
||||
surfaceContainerHighest: _darkSurfaceVar,
|
||||
onSurfaceVariant: _darkOnSurfaceVar,
|
||||
outline: _darkOutline,
|
||||
outlineVariant: _darkOutline,
|
||||
shadow: Colors.black,
|
||||
scrim: Colors.black,
|
||||
inverseSurface: _darkOnSurface,
|
||||
onInverseSurface: _darkSurface,
|
||||
inversePrimary: _lightPrimary,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _darkBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
|
||||
cardTheme: CardThemeData(
|
||||
color: _darkSurface,
|
||||
elevation: 2,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: _darkSurface,
|
||||
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: _darkSurface,
|
||||
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: _darkSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _darkPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _darkSurfaceVar,
|
||||
labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12),
|
||||
side: BorderSide(color: _darkOutline),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData fabledLightTheme() {
|
||||
final cs = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
primary: _lightPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: const Color(0xFFEDE5FF),
|
||||
onPrimaryContainer: _lightOnSurface,
|
||||
secondary: _lightPrimary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: _lightSurfaceVar,
|
||||
onSecondaryContainer: _lightOnSurface,
|
||||
tertiary: _lightPrimary,
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _lightSurfaceVar,
|
||||
onTertiaryContainer: _lightOnSurface,
|
||||
error: const Color(0xFFDC2626),
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFFFEE2E2),
|
||||
onErrorContainer: const Color(0xFF7F1D1D),
|
||||
surface: _lightSurface,
|
||||
onSurface: _lightOnSurface,
|
||||
surfaceContainerHighest: _lightSurfaceVar,
|
||||
onSurfaceVariant: _lightOnSurfaceVar,
|
||||
outline: _lightOutline,
|
||||
outlineVariant: _lightOutline,
|
||||
shadow: Colors.black,
|
||||
scrim: Colors.black,
|
||||
inverseSurface: _lightOnSurface,
|
||||
onInverseSurface: _lightSurface,
|
||||
inversePrimary: _darkPrimary,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: cs,
|
||||
scaffoldBackgroundColor: _lightBackground,
|
||||
textTheme: _buildTextTheme(ThemeData.light().textTheme),
|
||||
cardTheme: CardThemeData(
|
||||
color: _lightSurface,
|
||||
elevation: 1,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: _lightSurface,
|
||||
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: _lightSurface,
|
||||
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: _lightSurfaceVar,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightOutline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide(color: _lightPrimary, width: 2),
|
||||
),
|
||||
),
|
||||
dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: _lightSurfaceVar,
|
||||
labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12),
|
||||
side: BorderSide(color: _lightOutline),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GradientButton ─────────────────────────────────────────────────────────────
|
||||
// Use wherever the web app uses the indigo gradient button (send, primary actions).
|
||||
|
||||
class GradientButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const GradientButton({
|
||||
super.key,
|
||||
required this.onPressed,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = onPressed == null;
|
||||
return AnimatedOpacity(
|
||||
opacity: disabled ? 0.45 : 1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: disabled
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
|
||||
),
|
||||
color: disabled ? const Color(0xFF7C3AED) : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: disabled
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(padding: padding, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,16 @@ class _ErrorInterceptor extends Interceptor {
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (err.type == DioExceptionType.receiveTimeout ||
|
||||
err.type == DioExceptionType.sendTimeout) {
|
||||
handler.reject(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: const AppException('Request timed out. The server is taking too long to respond.'),
|
||||
type: err.type,
|
||||
response: err.response,
|
||||
));
|
||||
return;
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/briefing/articles/{itemId}/discuss body: {"conv_id": convId}
|
||||
/// Injects the article as context and triggers LLM generation.
|
||||
/// Returns the assistant_message_id of the generating placeholder.
|
||||
Future<int> discussArticle(int convId, int itemId) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/briefing/articles/$itemId/discuss',
|
||||
data: {'conv_id': convId},
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['assistant_message_id'] as int;
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/briefing/rss-reactions/{rssItemId}
|
||||
Future<void> deleteRssReaction(int rssItemId) async {
|
||||
try {
|
||||
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,18 @@ class ChatApi {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/chat/from-article/{itemId}
|
||||
/// Creates or retrieves a chat conversation seeded with the article.
|
||||
/// Returns the conversation_id.
|
||||
Future<int> openArticleInChat(int itemId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return data['conversation_id'] as int;
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/calendar_event.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class EventsApi {
|
||||
final Dio _dio;
|
||||
const EventsApi(this._dio);
|
||||
|
||||
/// GET /api/events?from={iso}&to={iso}
|
||||
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/events',
|
||||
queryParameters: {
|
||||
'from': from.toUtc().toIso8601String(),
|
||||
'to': to.toUtc().toIso8601String(),
|
||||
},
|
||||
);
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/events
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/events', data: payload);
|
||||
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/events/{id}
|
||||
Future<CalendarEvent> updateEvent(
|
||||
int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final response = await _dio.patch('/api/events/$id', data: fields);
|
||||
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/events/{id}
|
||||
Future<void> deleteEvent(int id) async {
|
||||
try {
|
||||
await _dio.delete('/api/events/$id');
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/briefing_feed.dart';
|
||||
import '../models/news_item.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class NewsApi {
|
||||
final Dio _dio;
|
||||
const NewsApi(this._dio);
|
||||
|
||||
/// GET /api/briefing/news
|
||||
/// Returns up to [limit] items starting at [offset], optionally filtered by [feedId].
|
||||
Future<List<NewsItem>> getNewsItems({
|
||||
int days = 90,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
int? feedId,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'days': days,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
if (feedId != null) 'feed_id': feedId,
|
||||
};
|
||||
final response = await _dio.get(
|
||||
'/api/briefing/news',
|
||||
queryParameters: params,
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['items'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/feeds
|
||||
Future<List<BriefingFeed>> getFeeds() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/briefing/feeds');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,14 @@ class NotesApi {
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
String noteType = 'note',
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/notes', data: {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'tags': tags,
|
||||
'note_type': noteType,
|
||||
if (projectId != null) 'project_id': projectId,
|
||||
});
|
||||
return Note.fromJson(response.data as Map<String, dynamic>);
|
||||
@@ -53,12 +55,14 @@ class NotesApi {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
bool clearProject = false,
|
||||
String noteType = 'note',
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put('/api/notes/$id', data: {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'tags': tags,
|
||||
'note_type': noteType,
|
||||
if (clearProject) 'project_id': null else if (projectId != null) 'project_id': projectId,
|
||||
});
|
||||
return Note.fromJson(response.data as Map<String, dynamic>);
|
||||
|
||||
@@ -7,11 +7,19 @@ class ProjectsApi {
|
||||
final Dio _dio;
|
||||
const ProjectsApi(this._dio);
|
||||
|
||||
Future<List<Project>> getAll({String? status}) async {
|
||||
Future<List<Project>> getAll({
|
||||
String? status,
|
||||
String sort = 'updated_at',
|
||||
String order = 'desc',
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/projects',
|
||||
queryParameters: status != null ? {'status': status} : null,
|
||||
queryParameters: {
|
||||
'sort': sort,
|
||||
'order': order,
|
||||
if (status != null) 'status': status,
|
||||
},
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['projects'] as List<dynamic>;
|
||||
@@ -37,10 +45,12 @@ class ProjectsApi {
|
||||
String? description,
|
||||
String? goal,
|
||||
String? color,
|
||||
String status = 'active',
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/projects', data: {
|
||||
'title': title,
|
||||
'status': status,
|
||||
if (description != null && description.isNotEmpty)
|
||||
'description': description,
|
||||
if (goal != null && goal.isNotEmpty) 'goal': goal,
|
||||
|
||||
@@ -40,6 +40,7 @@ class QuickCaptureApi {
|
||||
final response = await _dio.post(
|
||||
'/api/quick-capture',
|
||||
data: {'text': text},
|
||||
options: Options(receiveTimeout: const Duration(seconds: 120)),
|
||||
);
|
||||
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class SettingsApi {
|
||||
const SettingsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
Future<void> syncTimezone(String ianaTimezone) async {
|
||||
await _dio.put<void>(
|
||||
'/api/settings',
|
||||
data: {'user_timezone': ianaTimezone},
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,24 @@ class TasksApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getByProject(int projectId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/notes',
|
||||
queryParameters: {
|
||||
'project_id': projectId,
|
||||
'is_task': 'true',
|
||||
'limit': 500,
|
||||
},
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['notes'] as List<dynamic>;
|
||||
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Task>> getSubTasks(int parentId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
|
||||
@@ -0,0 +1,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,35 @@
|
||||
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,20 @@
|
||||
class BriefingFeed {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String? category;
|
||||
|
||||
const BriefingFeed({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
this.category,
|
||||
});
|
||||
|
||||
factory BriefingFeed.fromJson(Map<String, dynamic> json) => BriefingFeed(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
category: json['category'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
class CalendarEvent {
|
||||
final int id;
|
||||
final String title;
|
||||
final DateTime startDt;
|
||||
final DateTime? endDt;
|
||||
final bool allDay;
|
||||
final String description;
|
||||
final String location;
|
||||
final String color;
|
||||
final String? recurrence;
|
||||
final int? projectId;
|
||||
final int? reminderMinutes;
|
||||
|
||||
const CalendarEvent({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.startDt,
|
||||
this.endDt,
|
||||
required this.allDay,
|
||||
required this.description,
|
||||
required this.location,
|
||||
required this.color,
|
||||
this.recurrence,
|
||||
this.projectId,
|
||||
this.reminderMinutes,
|
||||
});
|
||||
|
||||
factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
startDt: DateTime.parse(json['start_dt'] as String).toLocal(),
|
||||
endDt: json['end_dt'] != null
|
||||
? DateTime.parse(json['end_dt'] as String).toLocal()
|
||||
: null,
|
||||
allDay: json['all_day'] as bool? ?? false,
|
||||
description: json['description'] as String? ?? '',
|
||||
location: json['location'] as String? ?? '',
|
||||
color: json['color'] as String? ?? '',
|
||||
recurrence: json['recurrence'] as String?,
|
||||
projectId: json['project_id'] as int?,
|
||||
reminderMinutes: json['reminder_minutes'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Strips time from a DateTime, returning midnight local.
|
||||
/// Used as map keys in CalendarState.eventsByDay.
|
||||
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,12 @@ class Message {
|
||||
final String content;
|
||||
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,
|
||||
@@ -15,25 +21,47 @@ class Message {
|
||||
required this.content,
|
||||
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,
|
||||
);
|
||||
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,
|
||||
content: content ?? this.content,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt,
|
||||
metadata: metadata,
|
||||
toolCalls: toolCalls ?? this.toolCalls,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
class NewsItem {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String snippet;
|
||||
final String source;
|
||||
final DateTime? publishedAt;
|
||||
final List<String> topics;
|
||||
final String? reaction;
|
||||
|
||||
const NewsItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.snippet,
|
||||
required this.source,
|
||||
this.publishedAt,
|
||||
required this.topics,
|
||||
this.reaction,
|
||||
});
|
||||
|
||||
factory NewsItem.fromJson(Map<String, dynamic> json) => NewsItem(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
snippet: json['snippet'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
publishedAt: json['published_at'] != null
|
||||
? DateTime.tryParse(json['published_at'] as String)
|
||||
: null,
|
||||
topics: (json['topics'] as List<dynamic>?)
|
||||
?.cast<String>()
|
||||
.toList() ??
|
||||
[],
|
||||
reaction: json['reaction'] as String?,
|
||||
);
|
||||
}
|
||||
@@ -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,4 +1,6 @@
|
||||
import '../api/chat_api.dart';
|
||||
export '../api/chat_api.dart'
|
||||
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
|
||||
import '../models/conversation.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
@@ -14,6 +16,6 @@ class ChatRepository {
|
||||
_api.getMessages(conversationId);
|
||||
Future<void> sendMessage(int conversationId, String content) =>
|
||||
_api.sendMessage(conversationId, content);
|
||||
Stream<String> streamGeneration(int conversationId) =>
|
||||
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
|
||||
_api.streamGeneration(conversationId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../api/knowledge_api.dart';
|
||||
import '../models/knowledge_item.dart';
|
||||
|
||||
class KnowledgeRepository {
|
||||
final KnowledgeApi _api;
|
||||
const KnowledgeRepository(this._api);
|
||||
|
||||
Future<(List<int>, int)> fetchIds({
|
||||
String? noteType,
|
||||
List<String> tags = const [],
|
||||
String sort = 'modified',
|
||||
String? q,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) =>
|
||||
_api.fetchIds(
|
||||
noteType: noteType,
|
||||
tags: tags,
|
||||
sort: sort,
|
||||
q: q,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
|
||||
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) =>
|
||||
_api.fetchBatch(ids);
|
||||
|
||||
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) =>
|
||||
_api.fetchCounts(tags: tags);
|
||||
|
||||
Future<List<String>> fetchTags({String? noteType}) =>
|
||||
_api.fetchTags(noteType: noteType);
|
||||
}
|
||||
@@ -13,8 +13,10 @@ class NotesRepository {
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
String noteType = 'note',
|
||||
}) =>
|
||||
_api.create(title, body, tags: tags, projectId: projectId);
|
||||
_api.create(title, body,
|
||||
tags: tags, projectId: projectId, noteType: noteType);
|
||||
|
||||
Future<Note> update(
|
||||
int id,
|
||||
@@ -23,9 +25,13 @@ class NotesRepository {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
bool clearProject = false,
|
||||
String noteType = 'note',
|
||||
}) =>
|
||||
_api.update(id, title, body,
|
||||
tags: tags, projectId: projectId, clearProject: clearProject);
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
clearProject: clearProject,
|
||||
noteType: noteType);
|
||||
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,26 @@ class ProjectsRepository {
|
||||
final ProjectsApi _api;
|
||||
const ProjectsRepository(this._api);
|
||||
|
||||
Future<List<Project>> getAll({String? status}) => _api.getAll(status: status);
|
||||
Future<List<Project>> getAll({
|
||||
String? status,
|
||||
String sort = 'updated_at',
|
||||
String order = 'desc',
|
||||
}) =>
|
||||
_api.getAll(status: status, sort: sort, order: order);
|
||||
Future<Project> getOne(int id) => _api.getOne(id);
|
||||
Future<Project> create({
|
||||
required String title,
|
||||
String? description,
|
||||
String? goal,
|
||||
String? color,
|
||||
String status = 'active',
|
||||
}) =>
|
||||
_api.create(
|
||||
title: title, description: description, goal: goal, color: color);
|
||||
title: title,
|
||||
description: description,
|
||||
goal: goal,
|
||||
color: color,
|
||||
status: status);
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
||||
_api.update(id, fields);
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
|
||||
@@ -27,6 +27,7 @@ class TasksRepository {
|
||||
parentId: parentId,
|
||||
);
|
||||
|
||||
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
|
||||
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
||||
|
||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -4,14 +4,22 @@ 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/knowledge_api.dart';
|
||||
import '../data/api/voice_api.dart';
|
||||
import '../data/api/milestones_api.dart';
|
||||
import '../data/api/events_api.dart';
|
||||
import '../data/api/news_api.dart';
|
||||
import '../data/api/notes_api.dart';
|
||||
import '../data/api/projects_api.dart';
|
||||
import '../data/api/quick_capture_api.dart';
|
||||
import '../data/api/settings_api.dart';
|
||||
import '../data/api/tasks_api.dart';
|
||||
import '../data/repositories/auth_repository.dart';
|
||||
import '../data/repositories/chat_repository.dart';
|
||||
import '../data/repositories/knowledge_repository.dart';
|
||||
import '../data/repositories/voice_repository.dart';
|
||||
import '../data/repositories/milestones_repository.dart';
|
||||
import '../data/repositories/notes_repository.dart';
|
||||
import '../data/repositories/projects_repository.dart';
|
||||
@@ -80,3 +88,35 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||
return MilestonesRepository(ref.watch(milestonesApiProvider));
|
||||
});
|
||||
|
||||
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
|
||||
return KnowledgeApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
|
||||
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
|
||||
});
|
||||
|
||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
||||
return BriefingApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
||||
return SettingsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final voiceApiProvider = Provider<VoiceApi>((ref) {
|
||||
return VoiceApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
||||
return VoiceRepository(ref.watch(voiceApiProvider));
|
||||
});
|
||||
|
||||
final newsApiProvider = Provider<NewsApi>((ref) {
|
||||
return NewsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||
return EventsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
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 = StateNotifierProvider<AuthNotifier, AuthStatus>((ref) {
|
||||
return AuthNotifier(ref);
|
||||
});
|
||||
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthStatus> {
|
||||
final Ref _ref;
|
||||
|
||||
AuthNotifier(this._ref) : super(AuthStatus.unknown);
|
||||
class AuthNotifier extends Notifier<AuthStatus> {
|
||||
@override
|
||||
AuthStatus build() => AuthStatus.unknown;
|
||||
|
||||
Future<void> verify() async {
|
||||
try {
|
||||
final repo = _ref.read(authRepositoryProvider);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(String username, String password) async {
|
||||
final repo = _ref.read(authRepositoryProvider);
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.login(username, password);
|
||||
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
|
||||
state = AuthStatus.authenticated;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
final repo = _ref.read(authRepositoryProvider);
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.logout();
|
||||
} finally {
|
||||
state = AuthStatus.unauthenticated;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/api/chat_api.dart';
|
||||
import '../data/models/briefing_conversation.dart';
|
||||
import '../data/models/message.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Re-fetch the current briefing conversation 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 (mobile network handoff, app backgrounded mid-stream,
|
||||
/// reverse proxy dropping idle sockets) the send loop never observes close
|
||||
/// and [isBriefingStreamingProvider] 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(briefingApiProvider).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(isBriefingStreamingProvider.notifier).state = false;
|
||||
}
|
||||
} catch (_) {
|
||||
// Network hiccup — keep existing state; user can retry.
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a news article as context and trigger generation.
|
||||
///
|
||||
/// Mirrors sendReply() but calls the /discuss endpoint instead of
|
||||
/// /messages so the backend injects article content before generating.
|
||||
Future<void> discussArticle(int convId, int itemId) async {
|
||||
final conv = state.value;
|
||||
if (conv == null) return;
|
||||
final chatApi = ref.read(chatApiProvider);
|
||||
final briefingApi = ref.read(briefingApiProvider);
|
||||
|
||||
final previous = conv.messages;
|
||||
final placeholder = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.assistant,
|
||||
content: '',
|
||||
status: 'generating',
|
||||
);
|
||||
state = AsyncData(conv.copyWith(messages: [...previous, placeholder]));
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = true;
|
||||
|
||||
try {
|
||||
await briefingApi.discussArticle(convId, itemId);
|
||||
} catch (e) {
|
||||
state = AsyncData(conv.copyWith(messages: previous));
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
|
||||
await _pollUntilComplete(convId, streamedContent);
|
||||
}
|
||||
|
||||
/// Send a reply to today's briefing 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 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;
|
||||
}
|
||||
|
||||
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
|
||||
await _pollUntilComplete(convId, streamedContent);
|
||||
}
|
||||
|
||||
/// Consume an SSE stream into the current briefing conversation 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: the TCP
|
||||
/// connection is half-closed, Dio never sees the close, and `await for`
|
||||
/// hangs forever with [isBriefingStreamingProvider] stuck true. If no event
|
||||
/// arrives within the watchdog window we bail out and let the polling pass
|
||||
/// below reconcile state from the server.
|
||||
///
|
||||
/// Returns whether any text content was actually streamed (the polling
|
||||
/// pass uses this to decide whether it's safe to overwrite with a possibly
|
||||
/// empty server-side row).
|
||||
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 /api/briefing messages until the last assistant row is complete,
|
||||
/// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears
|
||||
/// [isBriefingStreamingProvider] at the end so the input can't stay locked.
|
||||
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
|
||||
final briefingApi = ref.read(briefingApiProvider);
|
||||
try {
|
||||
for (var attempt = 0; attempt < 20; attempt++) {
|
||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||
final fresh = await briefingApi.getMessages(convId);
|
||||
final done = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||
);
|
||||
final hasContent = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||
);
|
||||
final current = state.value;
|
||||
if (current != null && (!streamedContent || done || hasContent)) {
|
||||
state = AsyncData(current.copyWith(messages: fresh));
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} catch (_) {
|
||||
final current = state.value;
|
||||
if (current != null) {
|
||||
final msgs = current.messages;
|
||||
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||
state = AsyncData(current.copyWith(messages: [
|
||||
...msgs.sublist(0, msgs.length - 1),
|
||||
msgs.last.copyWith(status: 'complete'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ref.read(isBriefingStreamingProvider.notifier).state = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/calendar_event.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
// ─── CalendarState ────────────────────────────────────────────────────────────
|
||||
|
||||
class CalendarState {
|
||||
final Map<DateTime, List<CalendarEvent>> eventsByDay;
|
||||
final DateTime selectedDay;
|
||||
final DateTime focusedMonth;
|
||||
final DateTimeRange loadedRange;
|
||||
|
||||
const CalendarState({
|
||||
required this.eventsByDay,
|
||||
required this.selectedDay,
|
||||
required this.focusedMonth,
|
||||
required this.loadedRange,
|
||||
});
|
||||
|
||||
CalendarState copyWith({
|
||||
Map<DateTime, List<CalendarEvent>>? eventsByDay,
|
||||
DateTime? selectedDay,
|
||||
DateTime? focusedMonth,
|
||||
DateTimeRange? loadedRange,
|
||||
}) {
|
||||
return CalendarState(
|
||||
eventsByDay: eventsByDay ?? this.eventsByDay,
|
||||
selectedDay: selectedDay ?? this.selectedDay,
|
||||
focusedMonth: focusedMonth ?? this.focusedMonth,
|
||||
loadedRange: loadedRange ?? this.loadedRange,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CalendarNotifier ─────────────────────────────────────────────────────────
|
||||
|
||||
final calendarProvider =
|
||||
AsyncNotifierProvider<CalendarNotifier, CalendarState>(CalendarNotifier.new);
|
||||
|
||||
class CalendarNotifier extends AsyncNotifier<CalendarState> {
|
||||
@override
|
||||
Future<CalendarState> build() async {
|
||||
final now = DateTime.now();
|
||||
final today = dateOnly(now);
|
||||
// Fetch current month ± 1 month as the initial window.
|
||||
final from = DateTime(now.year, now.month - 1, 1);
|
||||
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
|
||||
final events = await ref.watch(eventsApiProvider).getEvents(from, to);
|
||||
return CalendarState(
|
||||
eventsByDay: _groupByDay(events),
|
||||
selectedDay: today,
|
||||
focusedMonth: DateTime(now.year, now.month),
|
||||
loadedRange: DateTimeRange(start: from, end: to),
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-fetch events for the current range without clearing state (no flicker).
|
||||
Future<void> refresh() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final events = await ref.read(eventsApiProvider).getEvents(
|
||||
current.loadedRange.start,
|
||||
current.loadedRange.end,
|
||||
);
|
||||
state = AsyncData(current.copyWith(eventsByDay: _groupByDay(events)));
|
||||
}
|
||||
|
||||
/// Synchronously updates selectedDay and focusedMonth. No API call.
|
||||
void selectDay(DateTime day) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
state = AsyncData(current.copyWith(
|
||||
selectedDay: dateOnly(day),
|
||||
focusedMonth: DateTime(day.year, day.month),
|
||||
));
|
||||
}
|
||||
|
||||
/// Updates focusedMonth. Fetches events for [month] if not already loaded.
|
||||
Future<void> loadMonth(DateTime month) async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final focused = DateTime(month.year, month.month);
|
||||
// Always update focusedMonth so TableCalendar shows the right page.
|
||||
state = AsyncData(current.copyWith(focusedMonth: focused));
|
||||
// Skip fetch if the first day of [month] is within the already-loaded range.
|
||||
final monthStart = DateTime(month.year, month.month, 1);
|
||||
if (!monthStart.isBefore(current.loadedRange.start) &&
|
||||
!monthStart.isAfter(current.loadedRange.end)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final from = DateTime(month.year, month.month, 1);
|
||||
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
|
||||
final events = await ref.read(eventsApiProvider).getEvents(from, to);
|
||||
final s = state.value!;
|
||||
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
|
||||
for (final e in events) {
|
||||
final key = dateOnly(e.startDt);
|
||||
(merged[key] ??= []).add(e);
|
||||
}
|
||||
final newStart =
|
||||
from.isBefore(s.loadedRange.start) ? from : s.loadedRange.start;
|
||||
final newEnd =
|
||||
to.isAfter(s.loadedRange.end) ? to : s.loadedRange.end;
|
||||
state = AsyncData(s.copyWith(
|
||||
eventsByDay: merged,
|
||||
loadedRange: DateTimeRange(start: newStart, end: newEnd),
|
||||
));
|
||||
} catch (_) {
|
||||
// Failures are silent — already-loaded months remain visible.
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts [event] into eventsByDay after a successful createEvent API call.
|
||||
void addEvent(CalendarEvent event) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final key = dateOnly(event.startDt);
|
||||
final updated =
|
||||
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
|
||||
(updated[key] ??= []).add(event);
|
||||
state = AsyncData(current.copyWith(eventsByDay: updated));
|
||||
}
|
||||
|
||||
/// Replaces the old entry for [updated.id] with [updated] after a successful
|
||||
/// updateEvent API call. Scans all buckets to handle date changes.
|
||||
void updateEvent(CalendarEvent updated) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final byDay =
|
||||
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
|
||||
for (final key in byDay.keys) {
|
||||
byDay[key] = byDay[key]!.where((e) => e.id != updated.id).toList();
|
||||
}
|
||||
final newKey = dateOnly(updated.startDt);
|
||||
(byDay[newKey] ??= []).add(updated);
|
||||
state = AsyncData(current.copyWith(eventsByDay: byDay));
|
||||
}
|
||||
|
||||
/// Removes event [id] from [date]'s bucket after a successful deleteEvent
|
||||
/// API call.
|
||||
void removeEvent(int id, DateTime date) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final key = dateOnly(date);
|
||||
final byDay =
|
||||
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
|
||||
byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList();
|
||||
state = AsyncData(current.copyWith(eventsByDay: byDay));
|
||||
}
|
||||
}
|
||||
|
||||
Map<DateTime, List<CalendarEvent>> _groupByDay(List<CalendarEvent> events) {
|
||||
final byDay = <DateTime, List<CalendarEvent>>{};
|
||||
for (final e in events) {
|
||||
final key = dateOnly(e.startDt);
|
||||
(byDay[key] ??= []).add(e);
|
||||
}
|
||||
return byDay;
|
||||
}
|
||||
@@ -4,16 +4,20 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
final captureQueueProvider =
|
||||
StateNotifierProvider<CaptureQueueNotifier, List<String>>(
|
||||
(ref) => CaptureQueueNotifier(ref.watch(sharedPreferencesProvider)),
|
||||
NotifierProvider<CaptureQueueNotifier, List<String>>(
|
||||
CaptureQueueNotifier.new,
|
||||
);
|
||||
|
||||
class CaptureQueueNotifier extends StateNotifier<List<String>> {
|
||||
class CaptureQueueNotifier extends Notifier<List<String>> {
|
||||
static const _key = 'capture_queue';
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
CaptureQueueNotifier(this._prefs)
|
||||
: super(_prefs.getStringList(_key) ?? []);
|
||||
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
|
||||
|
||||
@override
|
||||
List<String> build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return prefs.getStringList(_key) ?? [];
|
||||
}
|
||||
|
||||
Future<void> enqueue(String text) async {
|
||||
final updated = [...state, text];
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/exceptions.dart';
|
||||
import 'api_client_provider.dart';
|
||||
import 'capture_queue_provider.dart';
|
||||
import 'chat_provider.dart';
|
||||
|
||||
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
|
||||
class CaptureResult {
|
||||
final String message;
|
||||
final bool isError;
|
||||
const CaptureResult(this.message, {this.isError = false});
|
||||
}
|
||||
|
||||
/// The most recent capture result. UI watches this to show snackbars.
|
||||
/// Reset to null by the notifier before each new item so listeners always fire.
|
||||
final captureResultProvider =
|
||||
NotifierProvider<_CaptureResultNotifier, CaptureResult?>(
|
||||
_CaptureResultNotifier.new);
|
||||
|
||||
class _CaptureResultNotifier extends Notifier<CaptureResult?> {
|
||||
@override
|
||||
CaptureResult? build() => null;
|
||||
}
|
||||
|
||||
/// In-memory sequential work queue for quick captures.
|
||||
final captureWorkQueueProvider =
|
||||
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||
CaptureWorkQueueNotifier.new,
|
||||
);
|
||||
|
||||
class CaptureWorkQueueNotifier extends Notifier<List<String>> {
|
||||
bool _running = false;
|
||||
|
||||
@override
|
||||
List<String> build() => [];
|
||||
|
||||
/// Add text to the queue and start the drain loop if not already running.
|
||||
void enqueue(String text) {
|
||||
state = [...state, text];
|
||||
_drain();
|
||||
}
|
||||
|
||||
Future<void> _drain() async {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
try {
|
||||
while (state.isNotEmpty) {
|
||||
final text = state.first;
|
||||
// Signal "no result yet" so the same result value can re-trigger watch.
|
||||
ref.read(captureResultProvider.notifier).state = null;
|
||||
try {
|
||||
// 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();
|
||||
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
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 = const CaptureResult(
|
||||
"You're offline — capture saved and will retry automatically.",
|
||||
);
|
||||
break;
|
||||
} on AppException catch (e) {
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
ref.read(captureResultProvider.notifier).state =
|
||||
CaptureResult(e.message, isError: true);
|
||||
} catch (_) {
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
ref.read(captureResultProvider.notifier).state = const CaptureResult(
|
||||
'Failed to send. Please try again.',
|
||||
isError: true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,39 @@
|
||||
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 StateProvider so UI re-builds immediately when streaming starts/stops.
|
||||
final isStreamingProvider = StateProvider.family<bool, int>((ref, _) => false);
|
||||
// Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops.
|
||||
final isStreamingProvider =
|
||||
NotifierProvider.family<_IsStreamingNotifier, bool, int>(
|
||||
(convId) => _IsStreamingNotifier(convId),
|
||||
);
|
||||
|
||||
class _IsStreamingNotifier extends Notifier<bool> {
|
||||
// ignore: avoid_unused_constructor_parameters
|
||||
_IsStreamingNotifier(int convId);
|
||||
|
||||
@override
|
||||
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>>(
|
||||
@@ -20,22 +48,28 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
Future<Conversation> create(String title) async {
|
||||
final conv =
|
||||
await ref.read(chatRepositoryProvider).createConversation(title);
|
||||
state = AsyncData([conv, ...state.valueOrNull ?? []]);
|
||||
state = AsyncData([conv, ...state.value ?? []]);
|
||||
return conv;
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(chatRepositoryProvider).deleteConversation(id);
|
||||
state = AsyncData([
|
||||
for (final c in state.valueOrNull ?? [])
|
||||
for (final c in state.value ?? [])
|
||||
if (c.id != id) c,
|
||||
]);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
final list = state.valueOrNull;
|
||||
final list = state.value;
|
||||
if (list == null) return;
|
||||
state = AsyncData([
|
||||
for (final c in list)
|
||||
@@ -44,21 +78,164 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
}
|
||||
}
|
||||
|
||||
final messagesProvider = AsyncNotifierProvider.family<MessagesNotifier,
|
||||
List<Message>, int>(MessagesNotifier.new);
|
||||
final messagesProvider =
|
||||
AsyncNotifierProvider.family<MessagesNotifier, List<Message>, int>(
|
||||
(convId) => MessagesNotifier(convId),
|
||||
);
|
||||
|
||||
class MessagesNotifier extends AsyncNotifier<List<Message>> {
|
||||
final int _convId;
|
||||
MessagesNotifier(this._convId);
|
||||
|
||||
class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
||||
@override
|
||||
Future<List<Message>> build(int arg) async {
|
||||
Future<List<Message>> build() async {
|
||||
final (_, messages) =
|
||||
await ref.watch(chatRepositoryProvider).getMessages(arg);
|
||||
await ref.watch(chatRepositoryProvider).getMessages(_convId);
|
||||
return messages;
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final convId = arg;
|
||||
/// 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);
|
||||
final previousMessages = state.valueOrNull ?? [];
|
||||
|
||||
// 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);
|
||||
final previousMessages = state.value ?? [];
|
||||
|
||||
// Optimistic UI: show the user message + assistant placeholder immediately.
|
||||
final userMsg = Message(
|
||||
@@ -85,17 +262,51 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
@@ -132,7 +343,7 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
||||
} catch (_) {
|
||||
// Polling failed entirely — clear the generating placeholder so the UI
|
||||
// doesn't spin forever.
|
||||
final msgs = state.valueOrNull;
|
||||
final msgs = state.value;
|
||||
if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||
state = AsyncData([
|
||||
...msgs.sublist(0, msgs.length - 1),
|
||||
@@ -141,6 +352,7 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
||||
}
|
||||
} finally {
|
||||
ref.read(isStreamingProvider(convId).notifier).state = false;
|
||||
ref.read(streamingStatusProvider(convId).notifier).state = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/briefing_feed.dart';
|
||||
import '../data/models/news_item.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
// ─── NewsState ────────────────────────────────────────────────────────────────
|
||||
|
||||
class NewsState {
|
||||
final List<NewsItem> items;
|
||||
final int offset;
|
||||
final bool hasMore;
|
||||
final bool loadingMore;
|
||||
final int? selectedFeedId;
|
||||
final Map<int, String?> reactions;
|
||||
|
||||
const NewsState({
|
||||
required this.items,
|
||||
required this.offset,
|
||||
required this.hasMore,
|
||||
required this.loadingMore,
|
||||
required this.selectedFeedId,
|
||||
required this.reactions,
|
||||
});
|
||||
|
||||
NewsState copyWith({
|
||||
List<NewsItem>? items,
|
||||
int? offset,
|
||||
bool? hasMore,
|
||||
bool? loadingMore,
|
||||
Object? selectedFeedId = _sentinel,
|
||||
Map<int, String?>? reactions,
|
||||
}) {
|
||||
return NewsState(
|
||||
items: items ?? this.items,
|
||||
offset: offset ?? this.offset,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
selectedFeedId: selectedFeedId == _sentinel
|
||||
? this.selectedFeedId
|
||||
: selectedFeedId as int?,
|
||||
reactions: reactions ?? this.reactions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const _sentinel = Object();
|
||||
|
||||
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
|
||||
|
||||
final newsProvider =
|
||||
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
|
||||
|
||||
class NewsNotifier extends AsyncNotifier<NewsState> {
|
||||
static const _limit = 40;
|
||||
|
||||
@override
|
||||
Future<NewsState> build() async {
|
||||
final items = await ref.watch(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
);
|
||||
return NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: null,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-fetch the first page without clearing state (no flicker).
|
||||
Future<void> refresh() async {
|
||||
final current = state.value;
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
feedId: current?.selectedFeedId,
|
||||
);
|
||||
state = AsyncData(NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: current?.selectedFeedId,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = state.value;
|
||||
if (current == null || current.loadingMore || !current.hasMore) return;
|
||||
state = AsyncData(current.copyWith(loadingMore: true));
|
||||
try {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: current.offset,
|
||||
feedId: current.selectedFeedId,
|
||||
);
|
||||
final updatedReactions = Map<int, String?>.from(current.reactions);
|
||||
for (final item in items) {
|
||||
updatedReactions.putIfAbsent(item.id, () => item.reaction);
|
||||
}
|
||||
state = AsyncData(current.copyWith(
|
||||
items: [...current.items, ...items],
|
||||
offset: current.offset + items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
reactions: updatedReactions,
|
||||
));
|
||||
} catch (e) {
|
||||
final recovered = state.value ?? current;
|
||||
state = AsyncData(recovered.copyWith(loadingMore: false));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setFeed(int? feedId) async {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final items = await ref.read(newsApiProvider).getNewsItems(
|
||||
days: 90,
|
||||
limit: _limit,
|
||||
offset: 0,
|
||||
feedId: feedId,
|
||||
);
|
||||
state = AsyncData(NewsState(
|
||||
items: items,
|
||||
offset: items.length,
|
||||
hasMore: items.length == _limit,
|
||||
loadingMore: false,
|
||||
selectedFeedId: feedId,
|
||||
reactions: {for (final item in items) item.id: item.reaction},
|
||||
));
|
||||
} catch (e, st) {
|
||||
state = AsyncError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleReaction(int itemId, String reaction) {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
final prev = current.reactions[itemId];
|
||||
final next = prev == reaction ? null : reaction;
|
||||
state = AsyncData(current.copyWith(
|
||||
reactions: {...current.reactions, itemId: next},
|
||||
));
|
||||
final briefingApi = ref.read(briefingApiProvider);
|
||||
final future = next == null
|
||||
? briefingApi.deleteRssReaction(itemId)
|
||||
: briefingApi.postRssReaction(itemId, next);
|
||||
future.catchError((_) {
|
||||
final s = state.value;
|
||||
if (s != null) {
|
||||
state = AsyncData(s.copyWith(
|
||||
reactions: {...s.reactions, itemId: prev},
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
|
||||
|
||||
final feedsProvider =
|
||||
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
|
||||
|
||||
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
|
||||
@override
|
||||
Future<List<BriefingFeed>> build() async {
|
||||
return ref.watch(newsApiProvider).getFeeds();
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,15 @@ 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);
|
||||
state = AsyncData([...state.valueOrNull ?? [], note]);
|
||||
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,9 +45,10 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
clearProject: clearProject,
|
||||
noteType: noteType,
|
||||
);
|
||||
state = AsyncData([
|
||||
for (final n in state.valueOrNull ?? [])
|
||||
for (final n in state.value ?? [])
|
||||
if (n.id == id) updated else n,
|
||||
]);
|
||||
return updated;
|
||||
@@ -51,7 +57,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(notesRepositoryProvider).delete(id);
|
||||
state = AsyncData([
|
||||
for (final n in state.valueOrNull ?? [])
|
||||
for (final n in state.value ?? [])
|
||||
if (n.id != id) n,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
@@ -25,7 +33,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
goal: goal,
|
||||
color: color,
|
||||
);
|
||||
state = AsyncData([...state.valueOrNull ?? [], project]);
|
||||
state = AsyncData([...state.value ?? [], project]);
|
||||
return project;
|
||||
}
|
||||
|
||||
@@ -33,7 +41,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
final updated =
|
||||
await ref.read(projectsRepositoryProvider).update(id, fields);
|
||||
state = AsyncData([
|
||||
for (final p in state.valueOrNull ?? [])
|
||||
for (final p in state.value ?? [])
|
||||
if (p.id == id) updated else p,
|
||||
]);
|
||||
return updated;
|
||||
@@ -42,7 +50,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(projectsRepositoryProvider).delete(id);
|
||||
state = AsyncData([
|
||||
for (final p in state.valueOrNull ?? [])
|
||||
for (final p in state.value ?? [])
|
||||
if (p.id != id) p,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -15,16 +18,14 @@ final cookiesPathProvider = Provider<String>((ref) {
|
||||
});
|
||||
|
||||
final themeModeProvider =
|
||||
StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return ThemeModeNotifier(prefs);
|
||||
});
|
||||
NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);
|
||||
|
||||
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
ThemeModeNotifier(this._prefs)
|
||||
: super(_fromString(_prefs.getString(_kThemeMode)));
|
||||
class ThemeModeNotifier extends Notifier<ThemeMode> {
|
||||
@override
|
||||
ThemeMode build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return _fromString(prefs.getString(_kThemeMode));
|
||||
}
|
||||
|
||||
static ThemeMode _fromString(String? value) => switch (value) {
|
||||
'light' => ThemeMode.light,
|
||||
@@ -38,48 +39,103 @@ class ThemeModeNotifier extends StateNotifier<ThemeMode> {
|
||||
ThemeMode.dark => 'dark',
|
||||
_ => 'system',
|
||||
};
|
||||
await _prefs.setString(_kThemeMode, value);
|
||||
await ref.read(sharedPreferencesProvider).setString(_kThemeMode, value);
|
||||
state = mode;
|
||||
}
|
||||
}
|
||||
|
||||
final forgejoRepoUrlProvider =
|
||||
StateNotifierProvider<ForgejoRepoUrlNotifier, String?>((ref) {
|
||||
return ForgejoRepoUrlNotifier(ref.watch(sharedPreferencesProvider));
|
||||
});
|
||||
NotifierProvider<ForgejoRepoUrlNotifier, String?>(
|
||||
ForgejoRepoUrlNotifier.new);
|
||||
|
||||
class ForgejoRepoUrlNotifier extends StateNotifier<String?> {
|
||||
final SharedPreferences _prefs;
|
||||
ForgejoRepoUrlNotifier(this._prefs)
|
||||
: super(_prefs.getString(_kForgejoRepoUrl) ??
|
||||
'https://git.fabledsword.com/bvandeusen/FabledApp');
|
||||
class ForgejoRepoUrlNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return prefs.getString(_kForgejoRepoUrl) ??
|
||||
'https://git.fabledsword.com/bvandeusen/FabledApp';
|
||||
}
|
||||
|
||||
Future<void> setUrl(String url) async {
|
||||
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
await _prefs.setString(_kForgejoRepoUrl, clean);
|
||||
await ref.read(sharedPreferencesProvider).setString(_kForgejoRepoUrl, clean);
|
||||
state = clean;
|
||||
}
|
||||
}
|
||||
|
||||
final serverUrlProvider = StateNotifierProvider<ServerUrlNotifier, String?>((ref) {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return ServerUrlNotifier(prefs);
|
||||
});
|
||||
final serverUrlProvider =
|
||||
NotifierProvider<ServerUrlNotifier, String?>(ServerUrlNotifier.new);
|
||||
|
||||
class ServerUrlNotifier extends StateNotifier<String?> {
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl));
|
||||
class ServerUrlNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return prefs.getString(_kServerUrl);
|
||||
}
|
||||
|
||||
Future<void> setUrl(String url) async {
|
||||
// Strip trailing slash
|
||||
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
await _prefs.setString(_kServerUrl, clean);
|
||||
await ref.read(sharedPreferencesProvider).setString(_kServerUrl, clean);
|
||||
state = clean;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _prefs.remove(_kServerUrl);
|
||||
await ref.read(sharedPreferencesProvider).remove(_kServerUrl);
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
bool get rssEnabled {
|
||||
final data = state.value ?? {};
|
||||
return data['rss_enabled']?.toString().toLowerCase() == 'true';
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = AsyncData(await ref.read(settingsApiProvider).getAll());
|
||||
}
|
||||
}
|
||||
|
||||
final rssEnabledProvider = Provider<bool>((ref) {
|
||||
final settings = ref.watch(serverSettingsProvider).value ?? {};
|
||||
return settings['rss_enabled']?.toString().toLowerCase() == 'true';
|
||||
});
|
||||
|
||||
@@ -6,12 +6,22 @@ import 'api_client_provider.dart';
|
||||
final tasksProvider =
|
||||
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
|
||||
|
||||
final projectTasksProvider =
|
||||
FutureProvider.family<List<Task>, int>((ref, projectId) {
|
||||
return ref.watch(tasksRepositoryProvider).getByProject(projectId);
|
||||
});
|
||||
|
||||
class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
@override
|
||||
Future<List<Task>> build() async {
|
||||
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,
|
||||
@@ -28,14 +38,14 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
dueDate: dueDate,
|
||||
projectId: projectId,
|
||||
);
|
||||
state = AsyncData([...state.valueOrNull ?? [], task]);
|
||||
state = AsyncData([...state.value ?? [], task]);
|
||||
return task;
|
||||
}
|
||||
|
||||
Future<Task> updateTask(int id, Map<String, dynamic> fields) async {
|
||||
final updated = await ref.read(tasksRepositoryProvider).update(id, fields);
|
||||
state = AsyncData([
|
||||
for (final t in state.valueOrNull ?? [])
|
||||
for (final t in state.value ?? [])
|
||||
if (t.id == id) updated else t,
|
||||
]);
|
||||
return updated;
|
||||
@@ -44,7 +54,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(tasksRepositoryProvider).delete(id);
|
||||
state = AsyncData([
|
||||
for (final t in state.valueOrNull ?? [])
|
||||
for (final t in state.value ?? [])
|
||||
if (t.id != id) t,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
||||
enum UpdateStatus {
|
||||
idle,
|
||||
checking,
|
||||
downloading,
|
||||
readyToInstall,
|
||||
upToDate,
|
||||
error,
|
||||
}
|
||||
|
||||
class UpdateState {
|
||||
final UpdateStatus status;
|
||||
@@ -13,6 +23,7 @@ class UpdateState {
|
||||
final String? downloadUrl;
|
||||
final double downloadProgress;
|
||||
final String? errorMessage;
|
||||
final String? apkPath;
|
||||
|
||||
const UpdateState({
|
||||
this.status = UpdateStatus.idle,
|
||||
@@ -21,6 +32,7 @@ class UpdateState {
|
||||
this.downloadUrl,
|
||||
this.downloadProgress = 0.0,
|
||||
this.errorMessage,
|
||||
this.apkPath,
|
||||
});
|
||||
|
||||
UpdateState copyWith({
|
||||
@@ -30,6 +42,7 @@ class UpdateState {
|
||||
String? downloadUrl,
|
||||
double? downloadProgress,
|
||||
String? errorMessage,
|
||||
String? apkPath,
|
||||
}) =>
|
||||
UpdateState(
|
||||
status: status ?? this.status,
|
||||
@@ -38,6 +51,7 @@ class UpdateState {
|
||||
downloadUrl: downloadUrl ?? this.downloadUrl,
|
||||
downloadProgress: downloadProgress ?? this.downloadProgress,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
apkPath: apkPath ?? this.apkPath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,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';
|
||||
@@ -69,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;
|
||||
}
|
||||
}
|
||||
@@ -100,14 +109,15 @@ 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 getExternalStorageDirectory() ??
|
||||
await getTemporaryDirectory();
|
||||
final path = '${dir.path}/fabled_update.apk';
|
||||
final dir = await _apkDir();
|
||||
await _cleanupApks(dir);
|
||||
|
||||
final path = '${dir.path}/fabled_${state.latestVersion}.apk';
|
||||
|
||||
await Dio().download(
|
||||
state.downloadUrl!,
|
||||
@@ -119,14 +129,18 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
},
|
||||
);
|
||||
|
||||
await OpenFile.open(
|
||||
path,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
// Return to available so the user can retry install if they dismissed it.
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.available,
|
||||
status: UpdateStatus.readyToInstall,
|
||||
apkPath: path,
|
||||
downloadProgress: 1.0,
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -137,8 +151,69 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> install() async {
|
||||
final path = state.apkPath;
|
||||
if (path == null) return;
|
||||
|
||||
final installPermission = await Permission.requestInstallPackages.status;
|
||||
if (!installPermission.isGranted) {
|
||||
final result = await Permission.requestInstallPackages.request();
|
||||
if (!result.isGranted) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage:
|
||||
'Grant "Install unknown apps" permission for Fabled in Settings, then try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await OpenFile.open(
|
||||
path,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
|
||||
if (result.type == ResultType.done) {
|
||||
state = const UpdateState();
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage: 'Could not open installer: ${result.message}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
if (mounted) context.go(Routes.notes);
|
||||
if (mounted) context.go(Routes.briefing);
|
||||
} on AuthException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} on AppException catch (e) {
|
||||
@@ -90,7 +90,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
cookieJar: ref.read(cookieJarProvider),
|
||||
onSuccess: () async {
|
||||
await ref.read(authProvider.notifier).verify();
|
||||
if (mounted) context.go(Routes.notes);
|
||||
if (mounted) context.go(Routes.briefing);
|
||||
},
|
||||
),
|
||||
));
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,530 @@
|
||||
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 '../../widgets/news_card.dart';
|
||||
import 'briefing_history_screen.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../widgets/voice_mic_button.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) {
|
||||
final wasBackground = !_appInForeground;
|
||||
_appInForeground = state == AppLifecycleState.resumed;
|
||||
// On resume, force a message refresh. This also unfreezes a stuck
|
||||
// streaming state if the SSE socket died while the app was backgrounded
|
||||
// — silentRefresh won't do that because it guards on isStreaming.
|
||||
if (_appInForeground && wasBackground && mounted) {
|
||||
ref.read(briefingProvider.notifier).refreshMessages();
|
||||
}
|
||||
}
|
||||
|
||||
void _pollSilently() {
|
||||
if (!_appInForeground || !mounted) return;
|
||||
final isStreaming = ref.read(isBriefingStreamingProvider);
|
||||
if (isStreaming) return;
|
||||
ref.read(briefingProvider.notifier).silentRefresh();
|
||||
}
|
||||
|
||||
Future<void> _pullToRefresh() async {
|
||||
try {
|
||||
await ref.read(briefingProvider.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(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> _handleDiscuss(int convId, int itemId) async {
|
||||
try {
|
||||
await ref.read(briefingProvider.notifier).discussArticle(convId, itemId);
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to start discussion.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleReaction(int itemId, String reaction) async {
|
||||
final current = _reactions[itemId];
|
||||
final next = current == reaction ? null : reaction;
|
||||
setState(() => _reactions[itemId] = next);
|
||||
final api = ref.read(briefingApiProvider);
|
||||
try {
|
||||
if (next == null) {
|
||||
await api.deleteRssReaction(itemId);
|
||||
} else {
|
||||
await api.postRssReaction(itemId, reaction);
|
||||
}
|
||||
} catch (_) {
|
||||
setState(() => _reactions[itemId] = current);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleVoiceMode() async {
|
||||
final voice = ref.read(voiceProvider);
|
||||
if (voice.voiceModeActive) {
|
||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
||||
return;
|
||||
}
|
||||
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
||||
onTranscript: (transcript) async {
|
||||
await ref.read(briefingProvider.notifier).sendReply(transcript);
|
||||
},
|
||||
enableTts: true,
|
||||
onError: (msg) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
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 voiceState = ref.watch(voiceProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
ref.listen(briefingProvider, (prev, next) => _scrollToBottom());
|
||||
|
||||
// Feed streaming assistant content to VoiceNotifier for TTS.
|
||||
ref.listen(briefingProvider, (prev, next) {
|
||||
if (!voiceState.voiceModeActive) return;
|
||||
final conv = next.value;
|
||||
if (conv == null || conv.messages.isEmpty) return;
|
||||
final last = conv.messages.last;
|
||||
if (last.role != MessageRole.assistant) return;
|
||||
final isComplete = last.status != 'generating';
|
||||
ref
|
||||
.read(voiceProvider.notifier)
|
||||
.feedContent(last.content, isComplete: isComplete);
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
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: (err, stack) => 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) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
Widget body = Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _pullToRefresh,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
// AlwaysScrollable so pull-to-refresh fires even when
|
||||
// the briefing is empty or shorter than the viewport.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
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,
|
||||
convId: conv.id,
|
||||
reactions: _reactions,
|
||||
onReaction: _handleReaction,
|
||||
onDiscuss: _handleDiscuss,
|
||||
rssEnabled: ref.watch(rssEnabledProvider),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Progress bar while streaming
|
||||
if (isStreaming)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
|
||||
// Voice mode banner
|
||||
if (voiceState.voiceModeActive)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
child: const Text(
|
||||
'🎤 Listening… tap mic to exit voice mode',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFF87171),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Reply bar
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: voiceState.voiceModeActive
|
||||
? 'Listening…'
|
||||
: 'Reply to your briefing…',
|
||||
hintStyle: voiceState.voiceModeActive
|
||||
? const TextStyle(fontStyle: FontStyle.italic)
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
),
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.newline,
|
||||
enabled: !isStreaming && !voiceState.voiceModeActive,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
VoiceMicButton(
|
||||
mode: voiceState.mode,
|
||||
voiceModeActive: voiceState.voiceModeActive,
|
||||
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 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 int convId;
|
||||
final Map<int, String?> reactions;
|
||||
final void Function(int itemId, String reaction) onReaction;
|
||||
final void Function(int convId, int itemId) onDiscuss;
|
||||
final bool rssEnabled;
|
||||
|
||||
const _BriefingMessageItem({
|
||||
required this.message,
|
||||
required this.convId,
|
||||
required this.reactions,
|
||||
required this.onReaction,
|
||||
required this.onDiscuss,
|
||||
this.rssEnabled = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = message.metadata;
|
||||
final isAssistant = message.role == MessageRole.assistant;
|
||||
|
||||
// Weather: show card above when metadata.weather key is present (even if null value)
|
||||
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
|
||||
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
|
||||
|
||||
// RSS news cards — cap at 3 (only when RSS is enabled)
|
||||
final rssItems = <RssItemMeta>[];
|
||||
if (rssEnabled && isAssistant && meta != null) {
|
||||
final raw = (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
rssItems.addAll(raw.map(RssItemMeta.fromJson).take(3));
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (hasWeatherKey) WeatherCard(weather: weatherData),
|
||||
ChatMessageBubble(message: message),
|
||||
if (rssItems.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: rssItems.map((item) => NewsCard(
|
||||
item: item,
|
||||
reaction: reactions[item.id],
|
||||
onReaction: onReaction,
|
||||
onDiscuss: () => onDiscuss(convId, item.id),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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(0xFF7C3AED), Color(0xFF5B21B6)],
|
||||
),
|
||||
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,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
|
||||
import '../../data/models/calendar_event.dart';
|
||||
import '../../providers/calendar_provider.dart';
|
||||
import 'event_form_sheet.dart';
|
||||
|
||||
class CalendarScreen extends ConsumerWidget {
|
||||
const CalendarScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final calAsync = ref.watch(calendarProvider);
|
||||
final notifier = ref.read(calendarProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Calendar')),
|
||||
body: calAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Could not load calendar.'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(calendarProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (cal) => Column(
|
||||
children: [
|
||||
TableCalendar<CalendarEvent>(
|
||||
firstDay: DateTime(2020),
|
||||
lastDay: DateTime(2030),
|
||||
focusedDay: cal.focusedMonth,
|
||||
selectedDayPredicate: (day) => isSameDay(day, cal.selectedDay),
|
||||
eventLoader: (day) =>
|
||||
cal.eventsByDay[dateOnly(day)] ?? [],
|
||||
calendarFormat: CalendarFormat.month,
|
||||
headerStyle: const HeaderStyle(
|
||||
formatButtonVisible: false,
|
||||
titleCentered: true,
|
||||
),
|
||||
calendarStyle: CalendarStyle(
|
||||
selectedDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
todayDecoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
markerDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
onDaySelected: (selected, _) => notifier.selectDay(selected),
|
||||
onPageChanged: (focused) => notifier.loadMonth(focused),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(calendarProvider.notifier).refresh(),
|
||||
child: _AgendaList(
|
||||
events:
|
||||
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
|
||||
notifier: notifier,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: calAsync.hasValue
|
||||
? FloatingActionButton(
|
||||
onPressed: () => showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => EventFormSheet(
|
||||
event: null,
|
||||
initialDate: calAsync.value!.selectedDay,
|
||||
notifier: notifier,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agenda list ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _AgendaList extends StatelessWidget {
|
||||
final List<CalendarEvent> events;
|
||||
final CalendarNotifier notifier;
|
||||
|
||||
const _AgendaList({required this.events, required this.notifier});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (events.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(child: Text('No events')),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (_, i) =>
|
||||
_EventTile(event: events[i], notifier: notifier),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event tile ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EventTile extends StatelessWidget {
|
||||
final CalendarEvent event;
|
||||
final CalendarNotifier notifier;
|
||||
|
||||
const _EventTile({required this.event, required this.notifier});
|
||||
|
||||
Color _dotColor(BuildContext context) {
|
||||
if (event.color.isEmpty) return Theme.of(context).colorScheme.primary;
|
||||
try {
|
||||
return Color(int.parse(event.color.replaceFirst('#', '0xFF')));
|
||||
} catch (_) {
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
}
|
||||
|
||||
String _timeLabel() {
|
||||
if (event.allDay) return 'All day';
|
||||
final h = event.startDt.hour.toString().padLeft(2, '0');
|
||||
final m = event.startDt.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
radius: 6,
|
||||
backgroundColor: _dotColor(context),
|
||||
),
|
||||
title: Text(event.title),
|
||||
subtitle: Text(_timeLabel()),
|
||||
onTap: () => showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => EventFormSheet(
|
||||
event: event,
|
||||
initialDate: null,
|
||||
notifier: notifier,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/models/calendar_event.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/calendar_provider.dart';
|
||||
|
||||
class EventFormSheet extends ConsumerStatefulWidget {
|
||||
/// null = create mode; non-null = edit mode.
|
||||
final CalendarEvent? event;
|
||||
|
||||
/// Pre-fills start date in create mode. Ignored in edit mode.
|
||||
final DateTime? initialDate;
|
||||
|
||||
final CalendarNotifier notifier;
|
||||
|
||||
const EventFormSheet({
|
||||
super.key,
|
||||
required this.event,
|
||||
required this.initialDate,
|
||||
required this.notifier,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<EventFormSheet> createState() => _EventFormSheetState();
|
||||
}
|
||||
|
||||
class _EventFormSheetState extends ConsumerState<EventFormSheet> {
|
||||
late final TextEditingController _titleCtrl;
|
||||
late final TextEditingController _descCtrl;
|
||||
late final TextEditingController _locationCtrl;
|
||||
|
||||
late DateTime _startDt;
|
||||
DateTime? _endDt;
|
||||
late bool _allDay;
|
||||
String? _recurrence; // null = None; one of the FREQ= strings otherwise
|
||||
String _color = '';
|
||||
bool _saving = false;
|
||||
|
||||
bool get _isCreate => widget.event == null;
|
||||
|
||||
/// Maps RRULE string (or null) to display label for the dropdown.
|
||||
static const Map<String?, String> _knownRrules = {
|
||||
null: 'None',
|
||||
'FREQ=DAILY': 'Daily',
|
||||
'FREQ=WEEKLY': 'Weekly',
|
||||
'FREQ=MONTHLY': 'Monthly',
|
||||
'FREQ=YEARLY': 'Yearly',
|
||||
};
|
||||
|
||||
/// True when editing an event whose RRULE is not one of the 5 known patterns.
|
||||
bool get _isCustomRrule =>
|
||||
widget.event?.recurrence != null &&
|
||||
!_knownRrules.containsKey(widget.event!.recurrence);
|
||||
|
||||
static const List<String> _presetColors = [
|
||||
'#EF4444',
|
||||
'#F59E0B',
|
||||
'#10B981',
|
||||
'#7C3AED',
|
||||
'#8B5CF6',
|
||||
'#EC4899',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.event;
|
||||
_titleCtrl = TextEditingController(text: e?.title ?? '');
|
||||
_descCtrl = TextEditingController(text: e?.description ?? '');
|
||||
_locationCtrl = TextEditingController(text: e?.location ?? '');
|
||||
|
||||
if (e != null) {
|
||||
_startDt = e.startDt.toLocal();
|
||||
_endDt = e.endDt?.toLocal();
|
||||
_allDay = e.allDay;
|
||||
// Custom RRULEs are displayed as read-only; leave _recurrence = null.
|
||||
_recurrence = _isCustomRrule ? null : e.recurrence;
|
||||
_color = e.color;
|
||||
} else {
|
||||
final base = widget.initialDate ?? DateTime.now();
|
||||
final now = DateTime.now();
|
||||
final hour = now.minute >= 30 ? (now.hour + 1) % 24 : now.hour;
|
||||
_startDt = DateTime(base.year, base.month, base.day, hour);
|
||||
_allDay = false;
|
||||
_recurrence = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_descCtrl.dispose();
|
||||
_locationCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_titleCtrl.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Title is required.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
// Preserve unrecognised RRULEs unchanged on save.
|
||||
final rrule = _isCustomRrule ? widget.event!.recurrence : _recurrence;
|
||||
final payload = <String, dynamic>{
|
||||
'title': _titleCtrl.text.trim(),
|
||||
'start_dt': _startDt.toUtc().toIso8601String(),
|
||||
if (_endDt != null) 'end_dt': _endDt!.toUtc().toIso8601String(),
|
||||
'all_day': _allDay,
|
||||
'description': _descCtrl.text.trim(),
|
||||
'location': _locationCtrl.text.trim(),
|
||||
'color': _color,
|
||||
'recurrence': rrule,
|
||||
};
|
||||
final api = ref.read(eventsApiProvider);
|
||||
if (_isCreate) {
|
||||
final created = await api.createEvent(payload);
|
||||
widget.notifier.addEvent(created);
|
||||
} else {
|
||||
final updated = await api.updateEvent(widget.event!.id, payload);
|
||||
widget.notifier.updateEvent(updated);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to save event.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _delete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete this event?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(
|
||||
'Delete',
|
||||
style: TextStyle(
|
||||
color: Theme.of(dialogContext).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(eventsApiProvider).deleteEvent(widget.event!.id);
|
||||
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to delete event.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Date/time pickers ─────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _pickStartDate() async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _startDt,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d != null) {
|
||||
setState(() {
|
||||
_startDt = DateTime(
|
||||
d.year, d.month, d.day, _startDt.hour, _startDt.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickStartTime() async {
|
||||
final t = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_startDt),
|
||||
);
|
||||
if (t != null) {
|
||||
setState(() {
|
||||
_startDt = DateTime(
|
||||
_startDt.year, _startDt.month, _startDt.day, t.hour, t.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickEndDate() async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _endDt ?? _startDt,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (d != null) {
|
||||
setState(() {
|
||||
final prev = _endDt ?? _startDt;
|
||||
_endDt =
|
||||
DateTime(d.year, d.month, d.day, prev.hour, prev.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickEndTime() async {
|
||||
final t = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_endDt ?? _startDt),
|
||||
);
|
||||
if (t != null) {
|
||||
setState(() {
|
||||
final prev = _endDt ?? _startDt;
|
||||
_endDt = DateTime(
|
||||
prev.year, prev.month, prev.day, t.hour, t.minute);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Formatting helpers ────────────────────────────────────────────────────
|
||||
|
||||
String _fmtDate(DateTime dt) {
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
return '${months[dt.month - 1]} ${dt.day}, ${dt.year}';
|
||||
}
|
||||
|
||||
String _fmtTime(DateTime dt) =>
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
// ── Build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding:
|
||||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_isCreate ? 'New Event' : 'Edit Event',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
if (!_isCreate)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
onPressed: _saving ? null : _delete,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Title
|
||||
TextField(
|
||||
controller: _titleCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// All-day toggle
|
||||
SwitchListTile(
|
||||
title: const Text('All day'),
|
||||
value: _allDay,
|
||||
onChanged: (v) => setState(() => _allDay = v),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
// Start date
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.calendar_today_outlined),
|
||||
title: Text(_fmtDate(_startDt)),
|
||||
subtitle: const Text('Start date'),
|
||||
onTap: _pickStartDate,
|
||||
),
|
||||
|
||||
// Start time (hidden when all-day)
|
||||
if (!_allDay)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.access_time_outlined),
|
||||
title: Text(_fmtTime(_startDt)),
|
||||
subtitle: const Text('Start time'),
|
||||
onTap: _pickStartTime,
|
||||
),
|
||||
|
||||
// End date
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.event_outlined),
|
||||
title: Text(
|
||||
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
|
||||
subtitle: const Text('End date'),
|
||||
onTap: _pickEndDate,
|
||||
trailing: _endDt != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () => setState(() => _endDt = null),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
// End time (hidden when all-day or no end date)
|
||||
if (!_allDay && _endDt != null)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.access_time_outlined),
|
||||
title: Text(_fmtTime(_endDt!)),
|
||||
subtitle: const Text('End time'),
|
||||
onTap: _pickEndTime,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Repeat — show read-only tile for custom RRULEs
|
||||
if (_isCustomRrule)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.repeat_outlined),
|
||||
title: const Text('Custom (read-only)'),
|
||||
subtitle: Text(widget.event!.recurrence ?? ''),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.repeat_outlined),
|
||||
const SizedBox(width: 16),
|
||||
DropdownButton<String?>(
|
||||
value: _recurrence,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: _knownRrules.entries
|
||||
.map((entry) => DropdownMenuItem<String?>(
|
||||
value: entry.key,
|
||||
child: Text(entry.value),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _recurrence = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Description
|
||||
TextField(
|
||||
controller: _descCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Location
|
||||
TextField(
|
||||
controller: _locationCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Location',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Color chips
|
||||
Text('Color', style: Theme.of(context).textTheme.labelMedium),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
// "No color" chip
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _color = ''),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: _color.isEmpty
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
width: _color.isEmpty ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.block, size: 16),
|
||||
),
|
||||
),
|
||||
..._presetColors.map((hex) {
|
||||
final c =
|
||||
Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _color = hex),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: c,
|
||||
shape: BoxShape.circle,
|
||||
border: _color == hex
|
||||
? Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Save button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_isCreate ? 'Create' : 'Save'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:math' show min;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.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;
|
||||
@@ -16,12 +16,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();
|
||||
@@ -61,19 +105,57 @@ 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)
|
||||
.valueOrNull
|
||||
.value
|
||||
?.where((c) => c.id == widget.conversationId)
|
||||
.firstOrNull
|
||||
?.title;
|
||||
@@ -81,6 +163,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(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
@@ -88,25 +183,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) =>
|
||||
_MessageBubble(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(
|
||||
@@ -117,23 +242,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,
|
||||
@@ -152,43 +291,3 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
const _MessageBubble({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUser = message.role == MessageRole.user;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(MediaQuery.of(context).size.width * 0.8, 480),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? scheme.primary : scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isUser ? 16 : 4),
|
||||
bottomRight: Radius.circular(isUser ? 4 : 16),
|
||||
),
|
||||
),
|
||||
child: isUser
|
||||
? Text(
|
||||
message.content,
|
||||
style: TextStyle(color: scheme.onPrimary),
|
||||
)
|
||||
: MarkdownBody(
|
||||
data: message.content.isEmpty ? '...' : message.content,
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +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 '../../core/exceptions.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
class ConversationsListScreen extends ConsumerStatefulWidget {
|
||||
const ConversationsListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConversationsListScreen> createState() =>
|
||||
_ConversationsListScreenState();
|
||||
}
|
||||
|
||||
class _ConversationsListScreenState
|
||||
extends ConsumerState<ConversationsListScreen> {
|
||||
int? _selectedConvId;
|
||||
|
||||
Future<void> _newConversation(bool isWide) async {
|
||||
try {
|
||||
final conv = await ref.read(conversationsProvider.notifier).create('');
|
||||
if (!mounted) return;
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = conv.id);
|
||||
} else {
|
||||
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
|
||||
}
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final convsAsync = ref.watch(conversationsProvider);
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
|
||||
// Clear stale selection when switching to narrow mode.
|
||||
if (!isWide && _selectedConvId != null) {
|
||||
_selectedConvId = null;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: isWide
|
||||
? Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _buildListPane(convsAsync, isWide)),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: const Text('New conversation'),
|
||||
onTap: () => _newConversation(isWide),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: _buildDetailPane()),
|
||||
],
|
||||
)
|
||||
: _buildListPane(convsAsync, isWide),
|
||||
floatingActionButton: isWide
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
heroTag: 'chat_fab',
|
||||
onPressed: () => _newConversation(isWide),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailPane() {
|
||||
if (_selectedConvId == null) {
|
||||
return const Center(child: Text('Select a conversation to open it.'));
|
||||
}
|
||||
return ChatScreen(
|
||||
key: ValueKey(_selectedConvId),
|
||||
conversationId: _selectedConvId!,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListPane(AsyncValue convsAsync, bool isWide) {
|
||||
return convsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Could not load conversations.'),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(conversationsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (convs) {
|
||||
if (convs.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('No conversations yet. Tap + to start one.'));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(conversationsProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: convs.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final conv = convs[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(
|
||||
conv.title.isNotEmpty ? conv.title : 'New conversation',
|
||||
),
|
||||
subtitle: Text(
|
||||
conv.updatedAt.toLocal().toString().substring(0, 16),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
selected: isWide && _selectedConvId == conv.id,
|
||||
selectedTileColor:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
onTap: () {
|
||||
if (isWide) {
|
||||
setState(() => _selectedConvId = conv.id);
|
||||
} else {
|
||||
context.push(
|
||||
Routes.chat.replaceFirst(':id', '${conv.id}'),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete conversation?'),
|
||||
content: Text(
|
||||
conv.title.isNotEmpty
|
||||
? conv.title
|
||||
: 'New conversation',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
await ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.delete(conv.id);
|
||||
if (mounted && _selectedConvId == conv.id) {
|
||||
setState(() => _selectedConvId = null);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import 'chat_screen.dart';
|
||||
|
||||
class ConversationsTabScreen extends ConsumerStatefulWidget {
|
||||
const ConversationsTabScreen({super.key});
|
||||
|
||||
@override
|
||||
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),
|
||||
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);
|
||||
|
||||
final listPanel = Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat', style: theme.textTheme.titleLarge),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'New conversation',
|
||||
onPressed: _createConversation,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: convsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (convs) {
|
||||
if (convs.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline,
|
||||
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),
|
||||
label: const Text('Start a conversation'),
|
||||
onPressed: _createConversation,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
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.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(c.id, c.title),
|
||||
),
|
||||
onTap: () => _openConversation(c.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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(Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text('Select a conversation',
|
||||
style: theme.textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 '${dt.day}/${dt.month}/${dt.year}';
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers/knowledge_provider.dart';
|
||||
import '../../widgets/knowledge_item_card.dart';
|
||||
|
||||
// Type tab configuration: (label, noteType filter value)
|
||||
const _kTabs = [
|
||||
(label: 'All', type: null),
|
||||
(label: 'Notes', type: 'note'),
|
||||
(label: 'People', type: 'person'),
|
||||
(label: 'Places', type: 'place'),
|
||||
(label: 'Lists', type: 'list'),
|
||||
(label: 'Tasks', type: 'task'),
|
||||
];
|
||||
|
||||
class KnowledgeScreen extends ConsumerStatefulWidget {
|
||||
const KnowledgeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<KnowledgeScreen> createState() => _KnowledgeScreenState();
|
||||
}
|
||||
|
||||
class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
late final ScrollController _scrollController;
|
||||
bool _searchActive = false;
|
||||
final _searchController = TextEditingController();
|
||||
var _debounce = DateTime.now();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: _kTabs.length, vsync: this);
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_onScroll);
|
||||
// Trigger initial load
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(knowledgeProvider.notifier).refresh();
|
||||
});
|
||||
_tabController.addListener(_onTabChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTabChanged() {
|
||||
if (!_tabController.indexIsChanging) return;
|
||||
final newType = _kTabs[_tabController.index].type;
|
||||
ref.read(knowledgeProvider.notifier).setTypeFilter(newType);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final pos = _scrollController.position;
|
||||
if (pos.pixels >= pos.maxScrollExtent - 300) {
|
||||
ref.read(knowledgeProvider.notifier).hydrateNext();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String q) {
|
||||
final now = DateTime.now();
|
||||
_debounce = now;
|
||||
Future.delayed(const Duration(milliseconds: 400), () {
|
||||
if (_debounce == now && mounted) {
|
||||
ref.read(knowledgeProvider.notifier).setSearch(q);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _tabLabel(int index) => _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 ? Icons.close : Icons.search),
|
||||
onPressed: () {
|
||||
setState(() => _searchActive = !_searchActive);
|
||||
if (!_searchActive) {
|
||||
_searchController.clear();
|
||||
ref.read(knowledgeProvider.notifier).setSearch(null);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: List.generate(
|
||||
_kTabs.length,
|
||||
(i) => Tab(text: _tabLabel(i)),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Tag filter chips
|
||||
if (state.availableTags.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: state.availableTags.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 6),
|
||||
itemBuilder: (_, i) {
|
||||
final tag = state.availableTags[i];
|
||||
final active = state.activeTags.contains(tag);
|
||||
return FilterChip(
|
||||
label: Text(tag),
|
||||
selected: active,
|
||||
onSelected: (_) =>
|
||||
ref.read(knowledgeProvider.notifier).toggleTag(tag),
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Main list
|
||||
Expanded(
|
||||
child: _buildList(state),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _onFabTapped(context),
|
||||
tooltip: 'New',
|
||||
child: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(KnowledgeState state) {
|
||||
if (state.isLoadingIds && state.ids.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.error != null && state.ids.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Failed to load',
|
||||
style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
ref.read(knowledgeProvider.notifier).refresh(),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final items = state.orderedItems;
|
||||
if (items.isEmpty && !state.isLoadingIds && !state.isLoadingBatch) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No ${_kTabs[_tabController.index].label.toLowerCase()} yet.',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
|
||||
child: 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: Icons.description_outlined,
|
||||
label: 'Note',
|
||||
description: 'General note or document',
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
context.push('/notes/new', extra: {'noteType': 'note'});
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: Icons.person_outlined,
|
||||
label: 'Person',
|
||||
description: 'Contact, colleague, or reference person',
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
context.push('/notes/new', extra: {'noteType': 'person'});
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: Icons.place_outlined,
|
||||
label: 'Place',
|
||||
description: 'Location, venue, or place of interest',
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
context.push('/notes/new', extra: {'noteType': 'place'});
|
||||
},
|
||||
),
|
||||
_TypePickerRow(
|
||||
icon: Icons.checklist_outlined,
|
||||
label: 'List',
|
||||
description: 'Checklist or structured list',
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
context.push('/notes/new', extra: {'noteType': 'list'});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypePickerRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String description;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _TypePickerRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(label),
|
||||
subtitle: Text(description),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../data/models/milestone.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/milestones_provider.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class ProjectTasksScreen extends ConsumerStatefulWidget {
|
||||
final int projectId;
|
||||
const ProjectTasksScreen({super.key, required this.projectId});
|
||||
|
||||
@override
|
||||
ConsumerState<ProjectTasksScreen> createState() => _ProjectTasksScreenState();
|
||||
}
|
||||
|
||||
class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||
// Local optimistic status overrides — avoids a reload flash on every cycle tap.
|
||||
final Map<int, TaskStatus> _pendingStatus = {};
|
||||
|
||||
TaskStatus _effectiveStatus(Task task) =>
|
||||
_pendingStatus[task.id] ?? task.status;
|
||||
|
||||
TaskStatus _nextStatus(TaskStatus s) => switch (s) {
|
||||
TaskStatus.todo => TaskStatus.inProgress,
|
||||
TaskStatus.inProgress => TaskStatus.done,
|
||||
TaskStatus.done => TaskStatus.todo,
|
||||
};
|
||||
|
||||
Future<void> _cycleStatus(Task task) async {
|
||||
final current = _effectiveStatus(task);
|
||||
final next = _nextStatus(current);
|
||||
setState(() => _pendingStatus[task.id] = next);
|
||||
try {
|
||||
await ref
|
||||
.read(tasksRepositoryProvider)
|
||||
.update(task.id, {'status': next.value});
|
||||
// Sync the global tasks list so the library view stays consistent.
|
||||
ref.invalidate(tasksProvider);
|
||||
} catch (_) {
|
||||
// Revert optimistic change on error.
|
||||
setState(() => _pendingStatus.remove(task.id));
|
||||
}
|
||||
}
|
||||
|
||||
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(0xFF7C3AED);
|
||||
try {
|
||||
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||
} catch (_) {
|
||||
return const Color(0xFF7C3AED);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasksAsync = ref.watch(projectTasksProvider(widget.projectId));
|
||||
final milestonesAsync = ref.watch(projectMilestonesProvider(widget.projectId));
|
||||
final project = ref.watch(projectsProvider).value
|
||||
?.whereType<Project>()
|
||||
.where((p) => p.id == widget.projectId)
|
||||
.firstOrNull;
|
||||
|
||||
final color = _parseColor(project?.color);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
project?.title ?? 'Project',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (project?.description?.isNotEmpty == true)
|
||||
Text(
|
||||
project!.description!,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Edit project',
|
||||
onPressed: () =>
|
||||
context.push('/projects/${widget.projectId}/edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: tasksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error loading tasks: $e')),
|
||||
data: (tasks) => _buildBody(context, tasks, milestonesAsync, color),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
List<Task> tasks,
|
||||
AsyncValue<List<Milestone>> milestonesAsync,
|
||||
Color color,
|
||||
) {
|
||||
final milestones = (milestonesAsync.value ?? []).toList()
|
||||
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
|
||||
|
||||
// Group tasks by milestoneId.
|
||||
final byMilestone = <int?, List<Task>>{};
|
||||
for (final t in tasks) {
|
||||
(byMilestone[t.milestoneId] ??= []).add(t);
|
||||
}
|
||||
|
||||
final unassigned = byMilestone[null] ?? [];
|
||||
|
||||
if (tasks.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_box_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No tasks in this project yet.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
setState(() => _pendingStatus.clear());
|
||||
await Future.wait([
|
||||
ref.refresh(projectTasksProvider(widget.projectId).future),
|
||||
ref.refresh(projectMilestonesProvider(widget.projectId).future),
|
||||
]);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// Top colour strip.
|
||||
SliverToBoxAdapter(child: Container(height: 4, color: color)),
|
||||
|
||||
// Milestone sections.
|
||||
for (final ms in milestones) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _MilestoneHeader(
|
||||
milestone: ms,
|
||||
tasks: byMilestone[ms.id] ?? [],
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
if ((byMilestone[ms.id] ?? []).isNotEmpty)
|
||||
SliverList.builder(
|
||||
itemCount: byMilestone[ms.id]!.length,
|
||||
itemBuilder: (_, i) {
|
||||
final task = byMilestone[ms.id]![i];
|
||||
return _TaskRow(
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
onTap: () => _openTask(task.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// Unassigned tasks.
|
||||
if (unassigned.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||
child: Text(
|
||||
'No milestone',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: unassigned.length,
|
||||
itemBuilder: (_, i) {
|
||||
final task = unassigned[i];
|
||||
return _TaskRow(
|
||||
task: task,
|
||||
effectiveStatus: _effectiveStatus(task),
|
||||
onStatusTap: () => _cycleStatus(task),
|
||||
onTap: () => _openTask(task.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Milestone section header ──────────────────────────────────────────────────
|
||||
|
||||
class _MilestoneHeader extends StatelessWidget {
|
||||
final Milestone milestone;
|
||||
final List<Task> tasks;
|
||||
final Color color;
|
||||
|
||||
const _MilestoneHeader({
|
||||
required this.milestone,
|
||||
required this.tasks,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final done = tasks.where((t) => t.status == TaskStatus.done).length;
|
||||
final total = tasks.length;
|
||||
final pct = total > 0 ? done / total : 0.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
milestone.title,
|
||||
style: theme.textTheme.titleSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$done / $total',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (total > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: pct,
|
||||
minHeight: 2,
|
||||
color: color,
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
final TaskStatus effectiveStatus;
|
||||
final VoidCallback onStatusTap;
|
||||
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,
|
||||
};
|
||||
|
||||
Color _statusColor(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return switch (effectiveStatus) {
|
||||
TaskStatus.done => const Color(0xFF22C55E),
|
||||
TaskStatus.inProgress => cs.primary,
|
||||
TaskStatus.todo => cs.onSurfaceVariant,
|
||||
};
|
||||
}
|
||||
|
||||
Color _priorityColor(BuildContext context) => switch (task.priority) {
|
||||
TaskPriority.high => const Color(0xFFEF4444),
|
||||
TaskPriority.medium => const Color(0xFFF59E0B),
|
||||
_ => Colors.transparent,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(_statusIcon, color: _statusColor(context)),
|
||||
onPressed: onStatusTap,
|
||||
tooltip: 'Cycle status',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.title.isNotEmpty ? task.title : 'Untitled',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
decoration: effectiveStatus == TaskStatus.done
|
||||
? TextDecoration.lineThrough
|
||||
: null,
|
||||
color: effectiveStatus == TaskStatus.done
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: null,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (task.dueDate != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Due ${_formatDate(task.dueDate!)}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: task.dueDate!.isBefore(DateTime.now()) &&
|
||||
effectiveStatus != TaskStatus.done
|
||||
? const Color(0xFFEF4444)
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (task.priority == TaskPriority.high ||
|
||||
task.priority == TaskPriority.medium)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: _priorityColor(context),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
return '${months[dt.month - 1]} ${dt.day}';
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNewsItem(NewsState news, int i, {int cols = 1}) {
|
||||
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],
|
||||
snippetMaxLines: cols > 1 ? 5 : 2,
|
||||
onReaction: (itemId, reaction) =>
|
||||
ref.read(newsProvider.notifier).toggleReaction(itemId, reaction),
|
||||
onDiscuss: _openingChat.contains(item.id)
|
||||
? null
|
||||
: () => _handleDiscuss(item.id),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final newsAsync = ref.watch(newsProvider);
|
||||
final feedsAsync = ref.watch(feedsProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('News', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
'Last 90 days',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: newsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Could not load news.'),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(newsProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (news) => Column(
|
||||
children: [
|
||||
_FeedFilter(
|
||||
feeds: feedsAsync.value ?? [],
|
||||
selectedFeedId: news.selectedFeedId,
|
||||
onChanged: (feedId) =>
|
||||
ref.read(newsProvider.notifier).setFeed(feedId),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cols = constraints.maxWidth >= 900
|
||||
? 3
|
||||
: constraints.maxWidth >= 600
|
||||
? 2
|
||||
: 1;
|
||||
if (cols == 1) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
itemCount: news.items.length + 1,
|
||||
itemBuilder: (_, i) =>
|
||||
_buildNewsItem(news, i),
|
||||
);
|
||||
}
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: cols,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1.6,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(_, i) => _buildNewsItem(news, i, cols: cols),
|
||||
childCount: news.items.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (news.hasMore)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: news.loadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: FilledButton.tonal(
|
||||
onPressed: _loadMore,
|
||||
child: const Text('Load more'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class NoteDetailScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final noteAsync = ref.watch(noteDetailProvider(noteId));
|
||||
final allNotes = ref.watch(notesProvider).valueOrNull ?? [];
|
||||
final allNotes = ref.watch(notesProvider).value ?? [];
|
||||
|
||||
void navigateByTitle(String title) {
|
||||
final matches = allNotes.where(
|
||||
|
||||
@@ -11,7 +11,8 @@ import '../../widgets/project_selector.dart';
|
||||
|
||||
class NoteEditScreen extends ConsumerStatefulWidget {
|
||||
final int? noteId;
|
||||
const NoteEditScreen({super.key, this.noteId});
|
||||
final String? noteType; // passed when creating a typed note from KnowledgeScreen
|
||||
const NoteEditScreen({super.key, this.noteId, this.noteType});
|
||||
|
||||
@override
|
||||
ConsumerState<NoteEditScreen> createState() => _NoteEditScreenState();
|
||||
@@ -25,12 +26,14 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
int? _projectId;
|
||||
bool _preview = false;
|
||||
bool _saving = false;
|
||||
late String _noteType;
|
||||
|
||||
late final Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_noteType = widget.noteType ?? 'note';
|
||||
_initFuture =
|
||||
widget.noteId != null ? _loadExisting() : Future.value();
|
||||
}
|
||||
@@ -50,6 +53,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
_contentController.text = note.body;
|
||||
_tags = List<String>.from(note.tags);
|
||||
_projectId = note.projectId;
|
||||
_noteType = note.noteType;
|
||||
}
|
||||
|
||||
void _addTag(String raw) {
|
||||
@@ -108,6 +112,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
body,
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
noteType: _noteType,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
} else {
|
||||
@@ -118,6 +123,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
clearProject: _projectId == null,
|
||||
noteType: _noteType,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
@@ -138,7 +144,22 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
builder: (context, snapshot) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.noteId == null ? 'New Note' : 'Edit Note'),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.noteId == null ? 'New Note' : 'Edit Note'),
|
||||
if (_noteType != 'note')
|
||||
Chip(
|
||||
label: Text(
|
||||
_noteType,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (widget.noteId != null)
|
||||
IconButton(
|
||||
|
||||
@@ -1,216 +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 '../../providers/notes_provider.dart';
|
||||
import 'note_detail_screen.dart';
|
||||
|
||||
class NotesListScreen extends ConsumerStatefulWidget {
|
||||
const NotesListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NotesListScreen> createState() => _NotesListScreenState();
|
||||
}
|
||||
|
||||
class _NotesListScreenState extends ConsumerState<NotesListScreen> {
|
||||
bool _showSearch = false;
|
||||
String _search = '';
|
||||
int? _selectedNoteId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notesAsync = ref.watch(notesProvider);
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
|
||||
// Clear stale selection when switching to narrow mode.
|
||||
if (!isWide && _selectedNoteId != null) {
|
||||
_selectedNoteId = null;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: isWide
|
||||
? Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: _buildListPane(notesAsync, isWide)),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: const Text('New note'),
|
||||
onTap: () => context.push(Routes.noteNew),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: _buildDetailPane()),
|
||||
],
|
||||
)
|
||||
: _buildListPane(notesAsync, isWide),
|
||||
floatingActionButton: isWide
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
heroTag: 'notes_fab',
|
||||
onPressed: () => context.push(Routes.noteNew),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailPane() {
|
||||
if (_selectedNoteId == null) {
|
||||
return const Center(child: Text('Select a note to read it.'));
|
||||
}
|
||||
return NoteDetailScreen(
|
||||
key: ValueKey(_selectedNoteId),
|
||||
noteId: _selectedNoteId!,
|
||||
onDeleted: () => setState(() => _selectedNoteId = null),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListPane(AsyncValue<List<Note>> notesAsync, bool isWide) {
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
if (_showSearch)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search notes…',
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: (v) =>
|
||||
setState(() => _search = v.trim().toLowerCase()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Close search',
|
||||
onPressed: () => setState(() {
|
||||
_showSearch = false;
|
||||
_search = '';
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: notesAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Could not load notes.'),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(notesProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (notes) {
|
||||
final filtered = _search.isEmpty
|
||||
? notes
|
||||
: notes
|
||||
.where((n) =>
|
||||
n.title.toLowerCase().contains(_search) ||
|
||||
n.body.toLowerCase().contains(_search))
|
||||
.toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
_search.isEmpty
|
||||
? 'No notes yet. Tap + to create one.'
|
||||
: 'No notes match "$_search".',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(notesProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final note = filtered[i];
|
||||
final preview = note.body
|
||||
.split('\n')
|
||||
.firstWhere((l) => l.trim().isNotEmpty,
|
||||
orElse: () => '')
|
||||
.trim();
|
||||
return ListTile(
|
||||
title: Text(note.title),
|
||||
subtitle: Text(
|
||||
preview.isNotEmpty
|
||||
? preview
|
||||
: note.updatedAt
|
||||
.toLocal()
|
||||
.toString()
|
||||
.substring(0, 16),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
selected: isWide && _selectedNoteId == note.id,
|
||||
selectedTileColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer,
|
||||
onTap: () {
|
||||
if (isWide) {
|
||||
setState(() => _selectedNoteId = note.id);
|
||||
} else {
|
||||
context.push(
|
||||
Routes.noteDetail
|
||||
.replaceFirst(':id', '${note.id}'),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Floating search button — only visible when search is closed.
|
||||
if (!_showSearch)
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => setState(() => _showSearch = true),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
|
||||
class ProjectEditScreen extends ConsumerStatefulWidget {
|
||||
final int? projectId;
|
||||
const ProjectEditScreen({super.key, this.projectId});
|
||||
|
||||
@override
|
||||
ConsumerState<ProjectEditScreen> createState() => _ProjectEditScreenState();
|
||||
}
|
||||
|
||||
class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
|
||||
final _titleController = TextEditingController();
|
||||
final _descController = TextEditingController();
|
||||
final _goalController = TextEditingController();
|
||||
String _status = 'active';
|
||||
bool _saving = false;
|
||||
late final Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initFuture =
|
||||
widget.projectId != null ? _loadExisting() : Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descController.dispose();
|
||||
_goalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadExisting() async {
|
||||
final projects = ref.read(projectsProvider).value ?? [];
|
||||
final project =
|
||||
projects.where((p) => p.id == widget.projectId).firstOrNull;
|
||||
if (project != null) {
|
||||
_titleController.text = project.title;
|
||||
_descController.text = project.description ?? '';
|
||||
_goalController.text = project.goal ?? '';
|
||||
_status = project.status;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final title = _titleController.text.trim();
|
||||
if (title.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Title is required.')));
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
if (widget.projectId == null) {
|
||||
await ref.read(projectsProvider.notifier).create(
|
||||
title: title,
|
||||
description: _descController.text.trim().isEmpty
|
||||
? null
|
||||
: _descController.text.trim(),
|
||||
goal: _goalController.text.trim().isEmpty
|
||||
? null
|
||||
: _goalController.text.trim(),
|
||||
);
|
||||
} else {
|
||||
await ref.read(projectsProvider.notifier).updateProject(
|
||||
widget.projectId!,
|
||||
{
|
||||
'title': title,
|
||||
'description': _descController.text.trim(),
|
||||
'goal': _goalController.text.trim(),
|
||||
'status': _status,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (mounted) context.pop();
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: _initFuture,
|
||||
builder: (context, snapshot) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.projectId == null ? 'New Project' : 'Edit Project'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check),
|
||||
onPressed: _saving ? null : _save,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: snapshot.connectionState == ConnectionState.waiting
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title *', border: OutlineInputBorder()),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _descController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
border: OutlineInputBorder()),
|
||||
maxLines: 3,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _goalController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Goal', border: OutlineInputBorder()),
|
||||
maxLines: 2,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
if (widget.projectId != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _status,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Status',
|
||||
border: OutlineInputBorder()),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'active', child: Text('Active')),
|
||||
DropdownMenuItem(
|
||||
value: 'completed', child: Text('Completed')),
|
||||
DropdownMenuItem(
|
||||
value: 'archived', child: Text('Archived')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _status = v!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,631 +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 '../../core/exceptions.dart';
|
||||
import '../../data/models/milestone.dart';
|
||||
import '../../data/models/project.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/milestones_provider.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class ProjectListScreen extends ConsumerWidget {
|
||||
const ProjectListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final projectsAsync = ref.watch(projectsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Projects')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showCreateDialog(context, ref),
|
||||
tooltip: 'New project',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: 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 yet.\nTap + to create one.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
final active =
|
||||
projects.where((p) => p.status == 'active').toList();
|
||||
final other =
|
||||
projects.where((p) => p.status != 'active').toList();
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: [
|
||||
if (active.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'Active (${active.length})'),
|
||||
...active.map((p) => _ProjectExpansionTile(project: p)),
|
||||
],
|
||||
if (other.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'Other'),
|
||||
...other.map((p) => _ProjectExpansionTile(project: p)),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateDialog(BuildContext context, WidgetRef ref) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _CreateProjectDialog(
|
||||
onCreate: (title, description, goal) async {
|
||||
try {
|
||||
await ref
|
||||
.read(projectsProvider.notifier)
|
||||
.create(title: title, description: description, goal: goal);
|
||||
} on AppException catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Section header ────────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium
|
||||
?.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Project expansion tile ────────────────────────────────────────────────────
|
||||
|
||||
class _ProjectExpansionTile extends ConsumerStatefulWidget {
|
||||
final Project project;
|
||||
const _ProjectExpansionTile({required this.project});
|
||||
|
||||
@override
|
||||
ConsumerState<_ProjectExpansionTile> createState() =>
|
||||
_ProjectExpansionTileState();
|
||||
}
|
||||
|
||||
class _ProjectExpansionTileState
|
||||
extends ConsumerState<_ProjectExpansionTile> {
|
||||
bool _expanded = false;
|
||||
|
||||
Color _statusColor(BuildContext context) => switch (widget.project.status) {
|
||||
'completed' => Colors.green,
|
||||
'archived' => Colors.grey,
|
||||
_ => Theme.of(context).colorScheme.primary,
|
||||
};
|
||||
|
||||
void _showOptions(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.check_circle_outline),
|
||||
title: const Text('Mark completed'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(projectsProvider.notifier).updateProject(
|
||||
widget.project.id,
|
||||
{'status': 'completed'},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.archive_outlined),
|
||||
title: const Text('Archive'),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(projectsProvider.notifier).updateProject(
|
||||
widget.project.id,
|
||||
{'status': 'archived'},
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete_outline,
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
title: Text('Delete',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Delete project?'),
|
||||
content: const Text(
|
||||
'Notes and tasks will be unlinked, not deleted.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true && context.mounted) {
|
||||
await ref
|
||||
.read(projectsProvider.notifier)
|
||||
.delete(widget.project.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statusColor = _statusColor(context);
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
final unfinished = tasksAsync.valueOrNull
|
||||
?.where((t) =>
|
||||
t.projectId == widget.project.id &&
|
||||
t.status != TaskStatus.done)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
final subtitle = unfinished.isEmpty
|
||||
? (widget.project.description != null &&
|
||||
widget.project.description!.isNotEmpty
|
||||
? widget.project.description!
|
||||
: null)
|
||||
: '${unfinished.length} task${unfinished.length == 1 ? '' : 's'} in progress';
|
||||
|
||||
return ExpansionTile(
|
||||
key: PageStorageKey('project-${widget.project.id}'),
|
||||
initiallyExpanded: false,
|
||||
onExpansionChanged: (v) => setState(() => _expanded = v),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: statusColor.withValues(alpha: 0.15),
|
||||
child: Icon(Icons.folder_outlined, color: statusColor, size: 20),
|
||||
),
|
||||
title: GestureDetector(
|
||||
onLongPress: () => _showOptions(context),
|
||||
child: Text(widget.project.title),
|
||||
),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
trailing: _StatusChip(status: widget.project.status),
|
||||
children: [
|
||||
if (_expanded)
|
||||
_ProjectTaskList(
|
||||
project: widget.project,
|
||||
unfinishedTasks: unfinished,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Expanded task list grouped by milestone ───────────────────────────────────
|
||||
|
||||
class _ProjectTaskList extends ConsumerWidget {
|
||||
final Project project;
|
||||
final List<Task> unfinishedTasks;
|
||||
const _ProjectTaskList({
|
||||
required this.project,
|
||||
required this.unfinishedTasks,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final milestonesAsync =
|
||||
ref.watch(projectMilestonesProvider(project.id));
|
||||
|
||||
return milestonesAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
error: (e, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Error loading milestones: $e',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
data: (milestones) {
|
||||
// Only active milestones in order
|
||||
final activeMilestones = milestones
|
||||
.where((m) => m.status == 'active')
|
||||
.toList()
|
||||
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
|
||||
|
||||
if (unfinishedTasks.isEmpty) {
|
||||
return _EmptyProjectContent(project: project);
|
||||
}
|
||||
|
||||
// Build milestone → tasks map
|
||||
final Map<int?, List<Task>> grouped = {};
|
||||
for (final task in unfinishedTasks) {
|
||||
grouped.putIfAbsent(task.milestoneId, () => []).add(task);
|
||||
}
|
||||
|
||||
final widgets = <Widget>[];
|
||||
|
||||
// Milestone groups (in order)
|
||||
for (final ms in activeMilestones) {
|
||||
final tasks = grouped[ms.id];
|
||||
if (tasks == null || tasks.isEmpty) continue;
|
||||
widgets.add(_MilestoneHeader(milestone: ms));
|
||||
for (final task in tasks) {
|
||||
widgets.add(_TaskRow(task: task));
|
||||
}
|
||||
}
|
||||
|
||||
// No-milestone group
|
||||
final noMilestoneTasks = grouped[null] ?? [];
|
||||
if (noMilestoneTasks.isNotEmpty) {
|
||||
if (activeMilestones.isNotEmpty) {
|
||||
widgets.add(const _NoMilestoneHeader());
|
||||
}
|
||||
for (final task in noMilestoneTasks) {
|
||||
widgets.add(_TaskRow(task: task));
|
||||
}
|
||||
}
|
||||
|
||||
widgets.add(_AddTaskRow(project: project));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: widgets,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyProjectContent extends StatelessWidget {
|
||||
final Project project;
|
||||
const _EmptyProjectContent({required this.project});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
'No open tasks.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
_AddTaskRow(project: project),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Milestone header ──────────────────────────────────────────────────────────
|
||||
|
||||
class _MilestoneHeader extends StatelessWidget {
|
||||
final Milestone milestone;
|
||||
const _MilestoneHeader({required this.milestone});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = milestone.total == 0 ? 0.0 : milestone.pct / 100.0;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.flag_outlined, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
milestone.title,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.secondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${milestone.completed}/${milestone.total}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: pct,
|
||||
minHeight: 3,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
backgroundColor:
|
||||
colorScheme.secondaryContainer.withValues(alpha: 0.4),
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(colorScheme.secondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoMilestoneHeader extends StatelessWidget {
|
||||
const _NoMilestoneHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
|
||||
child: Text(
|
||||
'No milestone',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Task row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _TaskRow extends StatelessWidget {
|
||||
final Task task;
|
||||
const _TaskRow({required this.task});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isInProgress = task.status == TaskStatus.inProgress;
|
||||
final dotColor = isInProgress ? colorScheme.primary : colorScheme.outline;
|
||||
final priorityColor = switch (task.priority) {
|
||||
TaskPriority.high => Colors.red,
|
||||
TaskPriority.medium => Colors.orange,
|
||||
TaskPriority.low => Colors.blue,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.fromLTRB(56, 0, 16, 0),
|
||||
leading: Icon(
|
||||
isInProgress ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: dotColor,
|
||||
),
|
||||
title: Text(task.title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: task.dueDate != null
|
||||
? Text(
|
||||
_formatDue(task.dueDate!),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _isDueOverdue(task.dueDate!)
|
||||
? colorScheme.error
|
||||
: colorScheme.outline,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: priorityColor != null
|
||||
? Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: priorityColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () => context.push(
|
||||
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDue(DateTime due) {
|
||||
final now = DateTime.now();
|
||||
final diff = due.difference(DateTime(now.year, now.month, now.day)).inDays;
|
||||
if (diff == 0) return 'Due today';
|
||||
if (diff == 1) return 'Due tomorrow';
|
||||
if (diff < 0) return 'Overdue ${(-diff)} day${(-diff) == 1 ? '' : 's'}';
|
||||
if (diff < 7) return 'Due in $diff days';
|
||||
return 'Due ${due.month}/${due.day}';
|
||||
}
|
||||
|
||||
bool _isDueOverdue(DateTime due) {
|
||||
final now = DateTime.now();
|
||||
return due.isBefore(DateTime(now.year, now.month, now.day));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add task row ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _AddTaskRow extends StatelessWidget {
|
||||
final Project project;
|
||||
const _AddTaskRow({required this.project});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(48, 4, 16, 8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.push('${Routes.taskNew}?projectId=${project.id}'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('New task'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.outline,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Status chip ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (status) {
|
||||
'completed' => ('Done', Colors.green),
|
||||
'archived' => ('Archived', Colors.grey),
|
||||
_ => ('Active', Theme.of(context).colorScheme.primary),
|
||||
};
|
||||
return Chip(
|
||||
label: Text(label, style: const TextStyle(fontSize: 11)),
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
||||
backgroundColor: color.withValues(alpha: 0.1),
|
||||
labelStyle: TextStyle(color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create project dialog ─────────────────────────────────────────────────────
|
||||
|
||||
class _CreateProjectDialog extends StatefulWidget {
|
||||
final Future<void> Function(String title, String? description, String? goal)
|
||||
onCreate;
|
||||
|
||||
const _CreateProjectDialog({required this.onCreate});
|
||||
|
||||
@override
|
||||
State<_CreateProjectDialog> createState() => _CreateProjectDialogState();
|
||||
}
|
||||
|
||||
class _CreateProjectDialogState extends State<_CreateProjectDialog> {
|
||||
final _titleController = TextEditingController();
|
||||
final _descController = TextEditingController();
|
||||
final _goalController = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descController.dispose();
|
||||
_goalController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final title = _titleController.text.trim();
|
||||
if (title.isEmpty) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.onCreate(
|
||||
title,
|
||||
_descController.text.trim().isEmpty ? null : _descController.text.trim(),
|
||||
_goalController.text.trim().isEmpty ? null : _goalController.text.trim(),
|
||||
);
|
||||
if (mounted) Navigator.pop(context);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('New Project'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _descController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _goalController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Goal (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _submit,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../data/models/project.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
|
||||
class ProjectsScreen extends ConsumerWidget {
|
||||
const ProjectsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final projectsAsync = ref.watch(projectsProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Projects')),
|
||||
body: projectsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (projects) => projects.isEmpty
|
||||
? const Center(child: Text('No projects yet.'))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => 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(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectCard extends StatelessWidget {
|
||||
final Project project;
|
||||
const _ProjectCard({required this.project});
|
||||
|
||||
Color _statusColor(BuildContext context) => switch (project.status) {
|
||||
'completed' => Colors.blue,
|
||||
'archived' => Colors.grey,
|
||||
_ => Theme.of(context).colorScheme.primary,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(project.title),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (project.description?.isNotEmpty == true)
|
||||
Text(
|
||||
project.description!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (project.goal?.isNotEmpty == true)
|
||||
Text(
|
||||
project.goal!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Chip(
|
||||
label: Text(
|
||||
project.status,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _statusColor(context),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onTap: () => context.push('/projects/${project.id}/tasks'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/notes_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class QuickCaptureScreen extends ConsumerStatefulWidget {
|
||||
const QuickCaptureScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QuickCaptureScreen> createState() =>
|
||||
_QuickCaptureScreenState();
|
||||
}
|
||||
|
||||
class _QuickCaptureScreenState extends ConsumerState<QuickCaptureScreen> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final result =
|
||||
await ref.read(quickCaptureApiProvider).capture(text);
|
||||
// Invalidate the relevant provider so the list screen re-fetches.
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
ref.invalidate(tasksProvider);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Use the server's human-readable message if available, else compose one.
|
||||
final msg = result.message.isNotEmpty
|
||||
? result.message
|
||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'note' => 'Note',
|
||||
'task' => 'Task',
|
||||
'event' => 'Event',
|
||||
'todo' => 'To-do',
|
||||
_ => type,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Quick Capture')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'What\'s on your mind?',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Describe a note, task, event, or research item — Fabled will figure out the rest.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
enabled: !_loading,
|
||||
maxLines: 6,
|
||||
minLines: 3,
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: InputDecoration(
|
||||
hintText:
|
||||
'e.g. "Remind me to call the dentist next Monday" or "Note: project meeting went well, key points were..."',
|
||||
border: const OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: (_loading || _controller.text.trim().isEmpty)
|
||||
? null
|
||||
: _send,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome),
|
||||
label: Text(_loading ? 'Processing...' : 'Capture'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ 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)
|
||||
@@ -131,7 +131,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');
|
||||
}
|
||||
@@ -202,12 +202,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.notes);
|
||||
context.go(Routes.briefing);
|
||||
} 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.briefing);
|
||||
} else {
|
||||
context.go(Routes.login);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/api/tasks_api.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
@@ -239,6 +238,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TaskStatus>(
|
||||
// ignore: deprecated_member_use
|
||||
value: _status,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Status',
|
||||
@@ -253,6 +253,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TaskPriority>(
|
||||
// ignore: deprecated_member_use
|
||||
value: _priority,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority',
|
||||
|
||||
@@ -1,199 +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/task.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
|
||||
class TasksListScreen extends ConsumerStatefulWidget {
|
||||
const TasksListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
|
||||
}
|
||||
|
||||
class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabs;
|
||||
bool _showSearch = false;
|
||||
String _search = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabs = TabController(length: 3, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabs.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tasksAsync = ref.watch(tasksProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// Search field — appears above the tabs when active
|
||||
if (_showSearch)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search tasks…',
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: (v) =>
|
||||
setState(() => _search = v.trim().toLowerCase()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Close search',
|
||||
onPressed: () => setState(() {
|
||||
_showSearch = false;
|
||||
_search = '';
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tab bar row — search icon sits to the right of the tabs
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: _tabs,
|
||||
tabs: const [
|
||||
Tab(text: 'To Do'),
|
||||
Tab(text: 'In Progress'),
|
||||
Tab(text: 'Done'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_showSearch)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: () => setState(() => _showSearch = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Content
|
||||
Expanded(
|
||||
child: tasksAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Could not load tasks.'),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(tasksProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (tasks) {
|
||||
final filtered = _search.isEmpty
|
||||
? tasks
|
||||
: tasks
|
||||
.where((t) =>
|
||||
t.title.toLowerCase().contains(_search) ||
|
||||
(t.description ?? '')
|
||||
.toLowerCase()
|
||||
.contains(_search))
|
||||
.toList();
|
||||
|
||||
final todo = filtered
|
||||
.where((t) => t.status == TaskStatus.todo)
|
||||
.toList();
|
||||
final inProgress = filtered
|
||||
.where((t) => t.status == TaskStatus.inProgress)
|
||||
.toList();
|
||||
final done = filtered
|
||||
.where((t) => t.status == TaskStatus.done)
|
||||
.toList();
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(tasksProvider.future),
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: [
|
||||
_TaskList(tasks: todo, search: _search),
|
||||
_TaskList(tasks: inProgress, search: _search),
|
||||
_TaskList(tasks: done, search: _search),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
heroTag: 'tasks_fab',
|
||||
onPressed: () => context.push(Routes.taskNew),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskList extends ConsumerWidget {
|
||||
final List<Task> tasks;
|
||||
final String search;
|
||||
const _TaskList({required this.tasks, this.search = ''});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (tasks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
search.isEmpty ? 'No tasks here.' : 'No tasks match "$search".',
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: tasks.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final task = tasks[i];
|
||||
return ListTile(
|
||||
leading: _priorityIcon(task.priority),
|
||||
title: Text(task.title),
|
||||
subtitle: task.dueDate != null
|
||||
? Text(
|
||||
'Due: ${task.dueDate!.toLocal().toString().substring(0, 10)}')
|
||||
: null,
|
||||
onTap: () => context.push(
|
||||
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _priorityIcon(TaskPriority p) {
|
||||
final color = switch (p) {
|
||||
TaskPriority.high => Colors.red,
|
||||
TaskPriority.medium => Colors.orange,
|
||||
TaskPriority.low => Colors.green,
|
||||
TaskPriority.none => Colors.grey,
|
||||
};
|
||||
return Icon(Icons.flag, color: color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.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.
|
||||
final Message? message;
|
||||
|
||||
/// Called when the user taps "Generate now".
|
||||
final VoidCallback? onGenerateNow;
|
||||
|
||||
const BriefingDigestCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onGenerateNow,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
|
||||
}
|
||||
|
||||
class _BriefingDigestCardState extends State<BriefingDigestCard> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Body
|
||||
if (widget.message == null) ...[
|
||||
Text(
|
||||
'No briefing yet today.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (widget.onGenerateNow != null)
|
||||
FilledButton.tonal(
|
||||
onPressed: widget.onGenerateNow,
|
||||
child: const Text('Generate now'),
|
||||
),
|
||||
] else ...[
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _expanded
|
||||
? MarkdownBody(data: widget.message!.content)
|
||||
: _TruncatedMarkdown(
|
||||
data: widget.message!.content,
|
||||
maxLines: 5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Text(
|
||||
_expanded ? 'Show less ↑' : 'Show more ↓',
|
||||
style: TextStyle(
|
||||
color: scheme.primary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 Markdown truncated to [maxLines] visible lines.
|
||||
class _TruncatedMarkdown extends StatelessWidget {
|
||||
final String data;
|
||||
final int maxLines;
|
||||
const _TruncatedMarkdown({required this.data, required this.maxLines});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxLines * 20.0),
|
||||
child: ClipRect(child: MarkdownBody(data: data)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
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 ConsumerWidget {
|
||||
final Message message;
|
||||
final String streamingStatus;
|
||||
const ChatMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.streamingStatus = '',
|
||||
});
|
||||
|
||||
@override
|
||||
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,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||
decoration: isUser
|
||||
? BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.35),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(16),
|
||||
bottomRight: Radius.circular(4),
|
||||
),
|
||||
)
|
||||
: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
left: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
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'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../data/models/knowledge_item.dart';
|
||||
|
||||
class KnowledgeItemCard extends StatelessWidget {
|
||||
final KnowledgeItem item;
|
||||
const KnowledgeItemCard({super.key, required this.item});
|
||||
|
||||
IconData get _icon => switch (item.noteType) {
|
||||
'person' => Icons.person_outlined,
|
||||
'place' => Icons.place_outlined,
|
||||
'list' => Icons.checklist_outlined,
|
||||
'task' => Icons.task_alt_outlined,
|
||||
_ => Icons.description_outlined,
|
||||
};
|
||||
|
||||
Color _statusColor(BuildContext context) {
|
||||
if (item.noteType != 'task') return Theme.of(context).colorScheme.primary;
|
||||
return switch (item.status) {
|
||||
'done' => Colors.green,
|
||||
'in_progress' => Colors.orange,
|
||||
'cancelled' => Colors.grey,
|
||||
_ => Theme.of(context).colorScheme.primary,
|
||||
};
|
||||
}
|
||||
|
||||
String? get _subtitle {
|
||||
if (item.noteType == 'task') {
|
||||
if (item.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: Text(
|
||||
item.title.isEmpty ? '(untitled)' : item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: _subtitle != null
|
||||
? Text(
|
||||
_subtitle!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
: null,
|
||||
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
|
||||
onTap: () => _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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../data/models/news_item.dart';
|
||||
|
||||
class RssItemMeta {
|
||||
final int id;
|
||||
final String title;
|
||||
final String url;
|
||||
final String source;
|
||||
final String snippet;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
const RssItemMeta({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.source,
|
||||
required this.snippet,
|
||||
this.publishedAt,
|
||||
});
|
||||
|
||||
factory RssItemMeta.fromJson(Map<String, dynamic> json) => RssItemMeta(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
snippet: json['snippet'] as String? ?? '',
|
||||
publishedAt: json['published_at'] != null
|
||||
? DateTime.tryParse(json['published_at'] as String)
|
||||
: null,
|
||||
);
|
||||
|
||||
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
source: item.source,
|
||||
snippet: item.snippet,
|
||||
publishedAt: item.publishedAt,
|
||||
);
|
||||
|
||||
String get relativeDate {
|
||||
if (publishedAt == null) return '';
|
||||
final diff = DateTime.now().difference(publishedAt!);
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inHours < 48) return 'Yesterday';
|
||||
return '${publishedAt!.month}/${publishedAt!.day}';
|
||||
}
|
||||
}
|
||||
|
||||
class NewsCard extends StatelessWidget {
|
||||
final RssItemMeta item;
|
||||
final String? reaction; // 'up' | 'down' | null
|
||||
final void Function(int itemId, String reaction) onReaction;
|
||||
final VoidCallback? onDiscuss;
|
||||
final int snippetMaxLines;
|
||||
|
||||
const NewsCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.reaction,
|
||||
required this.onReaction,
|
||||
this.onDiscuss,
|
||||
this.snippetMaxLines = 2,
|
||||
});
|
||||
|
||||
Future<void> _openUrl() async {
|
||||
if (item.url.isEmpty) return;
|
||||
final uri = Uri.tryParse(item.url);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Source + date row
|
||||
Row(
|
||||
children: [
|
||||
if (item.source.isNotEmpty)
|
||||
Text(
|
||||
item.source.toUpperCase(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: scheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
if (item.source.isNotEmpty && item.relativeDate.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
item.relativeDate,
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Title — tappable if URL present. Text stays in onSurface for
|
||||
// contrast; the primary-colored underline carries the "this is a
|
||||
// link" signal without tanking readability.
|
||||
GestureDetector(
|
||||
onTap: item.url.isNotEmpty ? _openUrl : null,
|
||||
child: Text(
|
||||
item.title,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
height: 1.3,
|
||||
decoration: item.url.isNotEmpty ? TextDecoration.underline : null,
|
||||
decorationColor: scheme.primary.withValues(alpha: 0.7),
|
||||
decorationThickness: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Snippet
|
||||
if (item.snippet.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.snippet,
|
||||
maxLines: snippetMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
// Actions row: reactions + discuss
|
||||
Row(
|
||||
children: [
|
||||
_ReactionButton(
|
||||
emoji: '👍',
|
||||
active: reaction == 'up',
|
||||
onTap: () => onReaction(item.id, 'up'),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_ReactionButton(
|
||||
emoji: '👎',
|
||||
active: reaction == 'down',
|
||||
onTap: () => onReaction(item.id, 'down'),
|
||||
),
|
||||
if (onDiscuss != null) ...[
|
||||
const Spacer(),
|
||||
_DiscussButton(onTap: onDiscuss!),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscussButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _DiscussButton({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: scheme.primary.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'Discuss',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReactionButton extends StatelessWidget {
|
||||
final String emoji;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ReactionButton({
|
||||
required this.emoji,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? scheme.primary.withValues(alpha: 0.12) : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: active ? scheme.primary : scheme.outlineVariant,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// Sticky banner shown when the backend is unreachable. Surfaces a retry
|
||||
/// action and reserves space for future "last sync X min ago" messaging once
|
||||
/// Tier 2 offline caching lands.
|
||||
class OfflineBanner extends ConsumerStatefulWidget {
|
||||
const OfflineBanner({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OfflineBanner> createState() => _OfflineBannerState();
|
||||
}
|
||||
|
||||
class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||
bool _retrying = false;
|
||||
|
||||
Future<void> _retry() async {
|
||||
if (_retrying) return;
|
||||
setState(() => _retrying = true);
|
||||
try {
|
||||
await ref.read(authProvider.notifier).verify();
|
||||
} finally {
|
||||
if (mounted) setState(() => _retrying = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = ref.watch(authProvider);
|
||||
if (status != AuthStatus.offline) return const SizedBox.shrink();
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: scheme.errorContainer,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off_outlined,
|
||||
size: 18, color: scheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Offline — showing cached data.',
|
||||
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),
|
||||
)
|
||||
: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,13 @@ class ProjectSelector extends ConsumerWidget {
|
||||
|
||||
return projectsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (projects) {
|
||||
final active =
|
||||
projects.where((p) => p.status == 'active').toList();
|
||||
|
||||
return DropdownButtonFormField<int?>(
|
||||
// ignore: deprecated_member_use
|
||||
value: value,
|
||||
decoration: decoration ??
|
||||
const InputDecoration(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.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 Icons.sticky_note_2_outlined;
|
||||
if (fn.contains('task')) return Icons.check_circle_outline;
|
||||
if (fn.contains('event') || fn.contains('calendar')) {
|
||||
return Icons.event_outlined;
|
||||
}
|
||||
if (fn.contains('project')) return Icons.folder_outlined;
|
||||
if (fn.contains('milestone')) return Icons.flag_outlined;
|
||||
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
|
||||
return Icons.public;
|
||||
}
|
||||
if (fn.contains('image')) return Icons.image_outlined;
|
||||
if (fn.contains('person') || fn.contains('profile')) {
|
||||
return Icons.person_outline;
|
||||
}
|
||||
if (fn.contains('place')) return Icons.place_outlined;
|
||||
if (fn.contains('rag') || fn.contains('scope')) return Icons.tune;
|
||||
if (fn.contains('calculate')) return Icons.calculate_outlined;
|
||||
return Icons.auto_awesome;
|
||||
}
|
||||
|
||||
/// 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(Icons.arrow_forward, 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,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../providers/voice_provider.dart';
|
||||
|
||||
/// Animated mic button that reflects the current [VoiceMode].
|
||||
///
|
||||
/// - idle: muted background, mic_none icon
|
||||
/// - recording: red, 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(Icons.volume_up, color: iconColor, size: 20),
|
||||
_ => Icon(Icons.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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class WeatherCard extends StatelessWidget {
|
||||
final Map<String, dynamic>? weather;
|
||||
|
||||
const WeatherCard({super.key, required this.weather});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (weather == null) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
'Weather data unavailable — will retry at next slot.',
|
||||
style: TextStyle(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final w = weather!;
|
||||
final location = w['location'] as String? ?? '';
|
||||
final currentTemp = w['current_temp'];
|
||||
final condition = w['condition'] as String? ?? '';
|
||||
final todayHigh = w['today_high'];
|
||||
final todayLow = w['today_low'];
|
||||
final yesterdayHigh = w['yesterday_high'];
|
||||
final fetchedAt = w['fetched_at'] as String?;
|
||||
final forecast = (w['forecast'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
|
||||
String? tempDelta;
|
||||
if (todayHigh != null && yesterdayHigh != null) {
|
||||
final diff = (todayHigh as num) - (yesterdayHigh as num);
|
||||
if (diff.abs() < 1) {
|
||||
tempDelta = 'Same as yesterday';
|
||||
} else {
|
||||
final dir = diff > 0 ? 'warmer' : 'cooler';
|
||||
tempDelta = '${diff.abs().round()}° $dir than yesterday';
|
||||
}
|
||||
}
|
||||
|
||||
String? fetchedLabel;
|
||||
if (fetchedAt != null) {
|
||||
try {
|
||||
final dt = DateTime.parse(fetchedAt).toLocal();
|
||||
final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12;
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
final period = dt.hour < 12 ? 'AM' : 'PM';
|
||||
fetchedLabel = '$h:$m $period';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: location + fetched time
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
location,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (fetchedLabel != null)
|
||||
Text(
|
||||
'as of $fetchedLabel',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Current temp + condition
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$currentTemp°',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: scheme.onSurface,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
condition,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Today high/low + delta
|
||||
if (todayHigh != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Today: $todayHigh° / $todayLow°'
|
||||
'${tempDelta != null ? ' · $tempDelta' : ''}',
|
||||
style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
// Forecast strip
|
||||
if (forecast.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: forecast.map((day) {
|
||||
return SizedBox(
|
||||
width: 64,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
day['day'] as String? ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
day['condition'] as String? ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${day['high']}° / ${day['low']}°',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,22 @@
|
||||
|
||||
#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 <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) 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,14 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_timezone
|
||||
open_file_linux
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
vad
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
@@ -5,14 +5,24 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import audio_session
|
||||
import flutter_inappwebview_macos
|
||||
import flutter_timezone
|
||||
import just_audio
|
||||
import open_file_mac
|
||||
import package_info_plus
|
||||
import record_macos
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
|
||||
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
|
||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
+502
-22
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "93.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -25,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 +73,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -81,6 +113,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cookie_jar:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -89,6 +129,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -109,26 +157,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.1"
|
||||
version: "5.9.2"
|
||||
dio_cookie_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio_cookie_manager
|
||||
sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0
|
||||
sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
version: "3.4.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
version: "2.1.2"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -153,6 +209,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -250,20 +314,36 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.3.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_timezone:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_timezone
|
||||
sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -276,18 +356,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
version: "17.1.0"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -296,6 +384,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -312,6 +408,22 @@ 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:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -320,6 +432,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:
|
||||
@@ -372,10 +508,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -404,10 +540,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
version: "0.17.5"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -480,14 +624,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -552,6 +704,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -576,6 +776,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -592,14 +800,86 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.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:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
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:
|
||||
@@ -656,11 +936,67 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_static:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
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_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.13"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -701,6 +1037,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:
|
||||
@@ -709,14 +1061,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.30.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
version: "0.7.10"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.16"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -733,6 +1101,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:
|
||||
@@ -749,6 +1197,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -757,6 +1213,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user