Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d9e4af6f3 | |||
| 0f05f47eef | |||
| 5bdd4f565b | |||
| dd250788f6 | |||
| 5e48a4fb69 | |||
| 01aa362d3c | |||
| 3c9602c7c9 | |||
| aba0ca6256 | |||
| 70a3279192 | |||
| fc6c9648f9 | |||
| 413b82f724 | |||
| 5f11b344a3 | |||
| 8959b62abe | |||
| c967a49e5a | |||
| 58d4cfab4d | |||
| ee0f354312 | |||
| bdaa5210f0 | |||
| ad20c9f9d4 | |||
| 48c134ce6a | |||
| 634b6d05cf | |||
| 00878a8a42 | |||
| ddbf867b03 | |||
| 51f1cffe79 | |||
| fa84e40efc | |||
| 1c4e3c018b | |||
| 75b7d6d0fe | |||
| 8ea244ecaa | |||
| a3fe0b4b61 | |||
| 6771ec5e81 | |||
| 4240c90d55 | |||
| 36644cf8a5 | |||
| cb5ce44bbe | |||
| 356709856f | |||
| 6e067f99ef | |||
| ab3a482705 | |||
| 47c190891e | |||
| 3e888b6458 | |||
| 6c29b685e8 | |||
| 5957551546 | |||
| d2582f9111 | |||
| 36350d35b1 | |||
| 96e6b6466f | |||
| d75d34ce8e | |||
| 1c97f9dea5 | |||
| c177bf0691 | |||
| 4ebc57d2e5 | |||
| 946b70ecc4 | |||
| 6ea268bf58 | |||
| 7e332530fb | |||
| 79dce1a01c | |||
| cb3a09756f | |||
| d441dcf954 | |||
| 03dc9108a3 | |||
| 5014eca9ac | |||
| d530920284 | |||
| e2a358a158 | |||
| 4919f7a185 | |||
| 5b639dbd4c | |||
| 334882520c | |||
| c4dca6d4ed | |||
| 776b394874 | |||
| b56c0fc02d | |||
| 8a0837a843 | |||
| 8e5a95b0f2 | |||
| 3a07221968 | |||
| e7d174cef7 | |||
| f4e39c00eb | |||
| e08a8906e3 | |||
| 01ea6b48db | |||
| b56f3d3a0f | |||
| 77fc82af45 | |||
| a2fc0d6c7d | |||
| 2a2f9e6e85 | |||
| 95d0f529ea | |||
| 36bc36cd9d | |||
| a23af0658a | |||
| 39d9f7e053 | |||
| 9944680c5b | |||
| 29ff9f821a | |||
| 1b08b2fd9e | |||
| 211bf0d658 | |||
| 2231c60bfb | |||
| 81077349a5 | |||
| 2cb566336b | |||
| 6e33d74178 | |||
| c90b0b3d48 | |||
| e891e8ba52 | |||
| 89fe8994fb | |||
| 3a3f44b00b | |||
| d97f7b0ebd | |||
| 2ab24a99a8 | |||
| e39d31fe43 | |||
| a50193dbc0 | |||
| f5ba2d25a3 | |||
| b5d9efa3ec | |||
| e0b56fc149 | |||
| 535833abfe | |||
| deec2318f7 | |||
| 2920252f13 | |||
| c8cdcbf230 | |||
| e89626a782 | |||
| ac4b2359a5 | |||
| f11e869a1b | |||
| 23509adfa8 | |||
| 24cef2e78c | |||
| 399da397da | |||
| 2bdd663719 | |||
| 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 | |||
| 3c3055d536 | |||
| 868cb0e49e | |||
| fceae5529d | |||
| f30aa8d273 | |||
| abf91874c3 | |||
| 54c2588bd6 | |||
| ccaec61de2 | |||
| 467fa6a553 | |||
| 140f6cf63a | |||
| 4c2b2a0d1a |
@@ -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
|
||||
|
||||
+3
-4
@@ -7,8 +7,7 @@ gradle-wrapper.jar
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
# Signing secrets — never commit these
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
fabled-release-key.jks
|
||||
app/release.jks
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +8,12 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fabledapp.fabled_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -20,21 +29,39 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.fabledapp.fabled_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
outputs.all {
|
||||
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
output?.outputFileName = "Fabled-${variant.versionCode}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<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
|
||||
android:label="fabled_app"
|
||||
android:label="Fabled"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
@@ -68,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)
|
||||
+366
-222
@@ -2,26 +2,39 @@ 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/notes_provider.dart';
|
||||
import 'providers/capture_work_queue_provider.dart';
|
||||
import 'providers/calendar_provider.dart';
|
||||
import 'providers/chat_provider.dart';
|
||||
import 'providers/journal_provider.dart';
|
||||
import 'providers/knowledge_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/update_provider.dart';
|
||||
import 'providers/tasks_provider.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/journal/journal_screen.dart';
|
||||
import 'screens/knowledge/knowledge_screen.dart';
|
||||
import 'screens/library/project_tasks_screen.dart';
|
||||
import 'screens/chat/chat_screen.dart';
|
||||
import 'screens/chat/conversations_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/settings/settings_screen.dart';
|
||||
import 'screens/setup/setup_screen.dart';
|
||||
import 'screens/splash/splash_screen.dart';
|
||||
import 'screens/tasks/task_edit_screen.dart';
|
||||
import 'screens/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
|
||||
@@ -30,6 +43,7 @@ class _RouterNotifier extends ChangeNotifier {
|
||||
_RouterNotifier(Ref ref) {
|
||||
ref.listen(authProvider, (_, _) => notifyListeners());
|
||||
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
|
||||
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +57,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;
|
||||
@@ -56,6 +71,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: [
|
||||
@@ -77,7 +102,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,
|
||||
@@ -93,7 +121,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.taskNew,
|
||||
builder: (_, _) => const TaskEditScreen(),
|
||||
builder: (_, state) => TaskEditScreen(
|
||||
initialProjectId: state.uri.queryParameters['projectId'] != null
|
||||
? int.tryParse(state.uri.queryParameters['projectId']!)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.taskEdit,
|
||||
@@ -101,6 +133,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(
|
||||
@@ -111,16 +159,24 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state, child) => _Shell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.notes,
|
||||
builder: (_, _) => const NotesListScreen(),
|
||||
path: Routes.journal,
|
||||
builder: (_, _) => const JournalScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.tasks,
|
||||
builder: (_, _) => const TasksListScreen(),
|
||||
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.calendar,
|
||||
builder: (_, _) => const CalendarScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -136,78 +192,140 @@ class _Shell extends ConsumerStatefulWidget {
|
||||
ConsumerState<_Shell> createState() => _ShellState();
|
||||
}
|
||||
|
||||
class _ShellState extends ConsumerState<_Shell> {
|
||||
static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations];
|
||||
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
|
||||
static const _baseTabs = [
|
||||
Routes.journal,
|
||||
Routes.knowledge,
|
||||
Routes.conversations,
|
||||
Routes.projects,
|
||||
];
|
||||
|
||||
List<String> _tabs() => [
|
||||
..._baseTabs,
|
||||
Routes.calendar,
|
||||
];
|
||||
|
||||
// Minimum gap between app-resume refreshes to avoid hammering the server.
|
||||
static const _resumeCooldown = Duration(seconds: 30);
|
||||
DateTime? _lastResumeRefresh;
|
||||
int? _prevTabIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 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 journal 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(knowledgeProvider.notifier).refresh();
|
||||
// journalProvider is an AsyncNotifier; invalidating is safe even if
|
||||
// the journal screen isn't currently mounted.
|
||||
ref.invalidate(journalProvider);
|
||||
}
|
||||
|
||||
/// Refresh only the provider backing the given shell tab route.
|
||||
void _refreshTab(String route) {
|
||||
if (route == Routes.journal) {
|
||||
ref.invalidate(journalProvider);
|
||||
} else if (route == Routes.knowledge) {
|
||||
ref.read(knowledgeProvider.notifier).refresh();
|
||||
} else if (route == Routes.conversations) {
|
||||
ref.read(conversationsProvider.notifier).refresh();
|
||||
} else if (route == Routes.calendar) {
|
||||
ref.read(calendarProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTimezone() async {
|
||||
try {
|
||||
final tzInfo = await FlutterTimezone.getLocalTimezone();
|
||||
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 (!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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -216,17 +334,24 @@ 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 tabs = _tabs();
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final index = _tabIndex(location);
|
||||
final index = _tabIndex(location, tabs);
|
||||
|
||||
// Refresh the incoming tab's data when switching between shell tabs.
|
||||
if (_prevTabIndex != null && _prevTabIndex != index) {
|
||||
final route = index < tabs.length ? tabs[index] : tabs[0];
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
|
||||
}
|
||||
_prevTabIndex = index;
|
||||
|
||||
final child = widget.child;
|
||||
// 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) {
|
||||
@@ -234,30 +359,41 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const OfflineBanner(),
|
||||
const _QuickCaptureBar(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (i) => context.go(_tabs[i]),
|
||||
onDestinationSelected: (i) => context.go(tabs[i]),
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.note_outlined),
|
||||
selectedIcon: Icon(Icons.note),
|
||||
label: Text('Notes'),
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: Text('Journal'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.check_box_outlined),
|
||||
selectedIcon: Icon(Icons.check_box),
|
||||
label: Text('Tasks'),
|
||||
icon: Icon(Icons.lightbulb_outline),
|
||||
selectedIcon: Icon(Icons.lightbulb),
|
||||
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'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.calendar_month_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_month),
|
||||
label: Text('Calendar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
@@ -274,18 +410,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.chat_bubble), label: 'Chat'),
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: 'Journal',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.lightbulb_outline),
|
||||
selectedIcon: Icon(Icons.lightbulb),
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -301,13 +466,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
|
||||
@@ -316,97 +479,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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} 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…';
|
||||
}
|
||||
@@ -414,61 +538,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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -485,17 +638,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,8 +9,14 @@ abstract class Routes {
|
||||
static const tasks = '/tasks';
|
||||
static const taskNew = '/tasks/new';
|
||||
static const taskEdit = '/tasks/:id/edit';
|
||||
static const knowledge = '/knowledge';
|
||||
static const projects = '/projects';
|
||||
static const projectEdit = '/projects/:id/edit';
|
||||
static const conversations = '/chat';
|
||||
static const chat = '/chat/:id';
|
||||
static const quickCapture = '/quick-capture';
|
||||
static const settings = '/settings';
|
||||
static const journal = '/journal';
|
||||
static const calendar = '/calendar';
|
||||
static const projectTasks = '/projects/:id/tasks';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
// ── Colour constants ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors the web frontend's design system (`docs/design-system.md` in
|
||||
// fabledscribe). Foundation pass shipped on web 2026-04-27 in `7a9a8b7`;
|
||||
// this is the equivalent palette swap for Flutter. Per-screen "surface
|
||||
// phase" reclassification (button Hybrid rule, border audit, etc.) is
|
||||
// deferred — most widgets read `colorScheme.primary` so the palette flip
|
||||
// alone covers a large surface.
|
||||
|
||||
// Dark mode — Obsidian / Iron / Pewter / Parchment (FabledSword baseline)
|
||||
const _darkBackground = Color(0xFF14171A); // Obsidian
|
||||
const _darkSurface = Color(0xFF1E2228); // Iron
|
||||
const _darkSurfaceVar = Color(0xFF2C313A); // Slate
|
||||
const _darkPrimary = Color(0xFF5B4A8A); // dusty violet (Scribe accent)
|
||||
const _darkPrimaryDeep = Color(0xFF3F3560); // gradient stop
|
||||
const _darkOnSurface = Color(0xFFE8E4D8); // Parchment
|
||||
const _darkOnSurfaceVar = Color(0xFFC2BFB4); // Vellum
|
||||
const _darkOutline = Color(0xFF3F4651); // Pewter
|
||||
|
||||
// Light mode — warm parchment (Scribe iteration)
|
||||
const _lightBackground = Color(0xFFF5F1E8); // warm cream page
|
||||
const _lightSurface = Color(0xFFFBF8F0); // near-white card
|
||||
const _lightSurfaceVar = Color(0xFFEFEAE0); // inset / hover surface
|
||||
const _lightPrimary = Color(0xFF5B4A8A); // dusty violet (same on both modes)
|
||||
const _lightOnSurface = Color(0xFF14171A); // deep ink (Obsidian inverted)
|
||||
const _lightOnSurfaceVar = Color(0xFF5A5852); // warm mid grey
|
||||
const _lightOutline = Color(0xFFD9D6CE); // warm light pewter
|
||||
|
||||
// Semantic — identical across themes
|
||||
const _semanticError = Color(0xFFC04A1F); // terracotta — validation/error
|
||||
const _semanticErrorBg = Color(0xFF7E2A1F); // Oxblood-hover for dark error container
|
||||
const _semanticErrorFg = Color(0xFFFEE2E2); // light text on dark error container
|
||||
|
||||
// Action tokens — Hybrid rule per the doc. These don't fit ColorScheme
|
||||
// natively (Material's primary/secondary/tertiary slots all carry the
|
||||
// brand accent), so they live on a ThemeExtension exposed below.
|
||||
const _actionPrimary = Color(0xFF4A5D3F); // Moss
|
||||
const _actionPrimaryHover = Color(0xFF5A6F4D);
|
||||
const _actionSecondary = Color(0xFF8B7355); // Bronze
|
||||
const _actionSecondaryHover = Color(0xFFA0876A);
|
||||
const _actionDestructive = Color(0xFF6B2118); // Oxblood
|
||||
const _actionDestructiveHover = Color(0xFF7E2A1F);
|
||||
const _actionGhostBorder = Color(0xFF3F4651); // Pewter (same as outline)
|
||||
|
||||
// ── Action ThemeExtension ─────────────────────────────────────────────────────
|
||||
// Read with: Theme.of(context).extension<ActionColors>()!.primary
|
||||
//
|
||||
// Flutter's ColorScheme has primary/secondary/tertiary all conceptually
|
||||
// "branded", whereas the doc's Hybrid rule reserves accent for brand
|
||||
// moments (Send, empty-state CTAs) and routes action buttons through a
|
||||
// separate Moss/Bronze/Oxblood/Pewter palette. This extension carries
|
||||
// those tokens without polluting ColorScheme.
|
||||
|
||||
@immutable
|
||||
class ActionColors extends ThemeExtension<ActionColors> {
|
||||
final Color primary; // Moss — Save / Confirm
|
||||
final Color primaryHover;
|
||||
final Color secondary; // Bronze — Cancel / alternate paths
|
||||
final Color secondaryHover;
|
||||
final Color destructive; // Oxblood — Delete / irreversible
|
||||
final Color destructiveHover;
|
||||
final Color ghostBorder; // Pewter — tertiary / "later" / "skip"
|
||||
|
||||
const ActionColors({
|
||||
required this.primary,
|
||||
required this.primaryHover,
|
||||
required this.secondary,
|
||||
required this.secondaryHover,
|
||||
required this.destructive,
|
||||
required this.destructiveHover,
|
||||
required this.ghostBorder,
|
||||
});
|
||||
|
||||
static const _kStandard = ActionColors(
|
||||
primary: _actionPrimary,
|
||||
primaryHover: _actionPrimaryHover,
|
||||
secondary: _actionSecondary,
|
||||
secondaryHover: _actionSecondaryHover,
|
||||
destructive: _actionDestructive,
|
||||
destructiveHover: _actionDestructiveHover,
|
||||
ghostBorder: _actionGhostBorder,
|
||||
);
|
||||
|
||||
@override
|
||||
ActionColors copyWith({
|
||||
Color? primary,
|
||||
Color? primaryHover,
|
||||
Color? secondary,
|
||||
Color? secondaryHover,
|
||||
Color? destructive,
|
||||
Color? destructiveHover,
|
||||
Color? ghostBorder,
|
||||
}) {
|
||||
return ActionColors(
|
||||
primary: primary ?? this.primary,
|
||||
primaryHover: primaryHover ?? this.primaryHover,
|
||||
secondary: secondary ?? this.secondary,
|
||||
secondaryHover: secondaryHover ?? this.secondaryHover,
|
||||
destructive: destructive ?? this.destructive,
|
||||
destructiveHover: destructiveHover ?? this.destructiveHover,
|
||||
ghostBorder: ghostBorder ?? this.ghostBorder,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ActionColors lerp(ThemeExtension<ActionColors>? other, double t) {
|
||||
if (other is! ActionColors) return this;
|
||||
return ActionColors(
|
||||
primary: Color.lerp(primary, other.primary, t)!,
|
||||
primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!,
|
||||
secondary: Color.lerp(secondary, other.secondary, t)!,
|
||||
secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!,
|
||||
destructive: Color.lerp(destructive, other.destructive, t)!,
|
||||
destructiveHover: Color.lerp(destructiveHover, other.destructiveHover, t)!,
|
||||
ghostBorder: Color.lerp(ghostBorder, other.ghostBorder, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typography ─────────────────────────────────────────────────────────────────
|
||||
// Inter for body / labels / titleMedium-and-below.
|
||||
// Fraunces for display / headline / titleLarge — only at ≥18px per the doc.
|
||||
// JetBrains Mono available via GoogleFonts.jetBrainsMono() at call sites for
|
||||
// code blocks (Flutter's TextTheme has no dedicated mono slot).
|
||||
|
||||
TextTheme _buildTextTheme(TextTheme base) {
|
||||
final inter = GoogleFonts.interTextTheme(base);
|
||||
final fraunces = GoogleFonts.frauncesTextTheme(base);
|
||||
return inter.copyWith(
|
||||
displayLarge: fraunces.displayLarge,
|
||||
displayMedium: fraunces.displayMedium,
|
||||
displaySmall: fraunces.displaySmall,
|
||||
headlineLarge: fraunces.headlineLarge,
|
||||
headlineMedium: fraunces.headlineMedium,
|
||||
headlineSmall: fraunces.headlineSmall,
|
||||
titleLarge: fraunces.titleLarge,
|
||||
// titleMedium / titleSmall / body* / label* stay Inter
|
||||
);
|
||||
}
|
||||
|
||||
// ── Themes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ThemeData fabledDarkTheme() {
|
||||
final cs = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
primary: _darkPrimary,
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: _darkPrimaryDeep,
|
||||
onPrimaryContainer: _darkOnSurface,
|
||||
secondary: _darkPrimary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: _darkSurfaceVar,
|
||||
onSecondaryContainer: _darkOnSurface,
|
||||
tertiary: _darkPrimary,
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _darkSurfaceVar,
|
||||
onTertiaryContainer: _darkOnSurface,
|
||||
error: _semanticError,
|
||||
onError: Colors.white,
|
||||
errorContainer: _semanticErrorBg,
|
||||
onErrorContainer: _semanticErrorFg,
|
||||
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),
|
||||
extensions: const [ActionColors._kStandard],
|
||||
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,
|
||||
// Warm parchment-tinted primary container, replacing the prior cool
|
||||
// indigo `#EDE5FF`. Used for chip/badge backgrounds at low alpha.
|
||||
primaryContainer: const Color(0xFFEDE9F4),
|
||||
onPrimaryContainer: _lightOnSurface,
|
||||
secondary: _lightPrimary,
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: _lightSurfaceVar,
|
||||
onSecondaryContainer: _lightOnSurface,
|
||||
tertiary: _lightPrimary,
|
||||
onTertiary: Colors.white,
|
||||
tertiaryContainer: _lightSurfaceVar,
|
||||
onTertiaryContainer: _lightOnSurface,
|
||||
error: _semanticError,
|
||||
onError: Colors.white,
|
||||
errorContainer: const Color(0xFFFEE2E2),
|
||||
onErrorContainer: _semanticErrorBg,
|
||||
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),
|
||||
extensions: const [ActionColors._kStandard],
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
// Brand-moment CTA equivalent of the web's `--gradient-cta` — chat send,
|
||||
// journal send, primary "Scribe-feature" actions. Reserve for those moments
|
||||
// per the doc's Hybrid rule; use FilledButton with ActionColors.primary
|
||||
// (Moss) for everything else.
|
||||
|
||||
class GradientButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
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: [_darkPrimary, _darkPrimaryDeep],
|
||||
),
|
||||
color: disabled ? _darkPrimary : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: disabled
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: _darkPrimary.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,27 @@ import '../models/conversation.dart';
|
||||
import '../models/message.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
sealed class ChatStreamEvent {}
|
||||
|
||||
class ChatTextChunk extends ChatStreamEvent {
|
||||
final String text;
|
||||
ChatTextChunk(this.text);
|
||||
}
|
||||
|
||||
class ChatStatusUpdate extends ChatStreamEvent {
|
||||
final String status; // empty string = clear status
|
||||
ChatStatusUpdate(this.status);
|
||||
}
|
||||
|
||||
/// A single tool call fired during generation. Mirrors the `tool_call` SSE
|
||||
/// event emitted by `generation_task.py` and the `tool_calls` array persisted
|
||||
/// on the assistant Message row — same shape either way so the UI can render
|
||||
/// live chips during streaming and re-render them from storage after reload.
|
||||
class ChatToolCall extends ChatStreamEvent {
|
||||
final Map<String, dynamic> toolCall;
|
||||
ChatToolCall(this.toolCall);
|
||||
}
|
||||
|
||||
class ChatApi {
|
||||
final Dio _dio;
|
||||
const ChatApi(this._dio);
|
||||
@@ -71,8 +92,8 @@ class ChatApi {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: GET the SSE stream and yield text chunks.
|
||||
Stream<String> streamGeneration(int conversationId) async* {
|
||||
// Step 2: GET the SSE stream and yield typed events (text chunks + status updates).
|
||||
Stream<ChatStreamEvent> streamGeneration(int conversationId) async* {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/chat/conversations/$conversationId/generation/stream',
|
||||
@@ -108,14 +129,29 @@ class ChatApi {
|
||||
if (data == '[DONE]') return;
|
||||
if (currentEvent == 'done' || currentEvent == 'error') return;
|
||||
|
||||
// Parse as JSON if possible, otherwise yield raw text.
|
||||
if (currentEvent == 'chunk' || currentEvent.isEmpty) {
|
||||
try {
|
||||
final obj = json.decode(data) as Map<String, dynamic>;
|
||||
final text = obj['text'] as String? ?? '';
|
||||
if (text.isNotEmpty) yield text;
|
||||
if (text.isNotEmpty) yield ChatTextChunk(text);
|
||||
} catch (_) {
|
||||
if (data.isNotEmpty) yield data;
|
||||
if (data.isNotEmpty) yield ChatTextChunk(data);
|
||||
}
|
||||
} else if (currentEvent == 'status') {
|
||||
try {
|
||||
final obj = json.decode(data) as Map<String, dynamic>;
|
||||
final status = obj['status'] as String? ?? '';
|
||||
yield ChatStatusUpdate(status);
|
||||
} catch (_) {
|
||||
// Ignore malformed status events
|
||||
}
|
||||
} else if (currentEvent == 'tool_call') {
|
||||
try {
|
||||
final obj = json.decode(data) as Map<String, dynamic>;
|
||||
final tc = obj['tool_call'];
|
||||
if (tc is Map<String, dynamic>) yield ChatToolCall(tc);
|
||||
} catch (_) {
|
||||
// Ignore malformed tool_call events
|
||||
}
|
||||
}
|
||||
} else if (line.isEmpty) {
|
||||
@@ -127,4 +163,5 @@ class ChatApi {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/calendar_event.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class EventsApi {
|
||||
final Dio _dio;
|
||||
const EventsApi(this._dio);
|
||||
|
||||
/// GET /api/events?from={iso}&to={iso}
|
||||
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/events',
|
||||
queryParameters: {
|
||||
'from': from.toUtc().toIso8601String(),
|
||||
'to': to.toUtc().toIso8601String(),
|
||||
},
|
||||
);
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/events
|
||||
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/events', data: payload);
|
||||
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// PATCH /api/events/{id}
|
||||
Future<CalendarEvent> updateEvent(
|
||||
int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final response = await _dio.patch('/api/events/$id', data: fields);
|
||||
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/events/{id}
|
||||
Future<void> deleteEvent(int id) async {
|
||||
try {
|
||||
await _dio.delete('/api/events/$id');
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/journal_day.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class JournalApi {
|
||||
final Dio _dio;
|
||||
const JournalApi(this._dio);
|
||||
|
||||
/// GET /api/journal/today
|
||||
/// Creates today's journal conversation + daily prep on demand if absent,
|
||||
/// then returns the day payload.
|
||||
Future<JournalDay> getToday() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/journal/today');
|
||||
return JournalDay.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/journal/day/<iso_date>
|
||||
Future<JournalDay> getDay(String isoDate) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/journal/day/$isoDate');
|
||||
return JournalDay.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/journal/days — list of dates with journal content, newest first.
|
||||
Future<List<String>> getDays() async {
|
||||
try {
|
||||
final response = await _dio.get('/api/journal/days');
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['days'] as List<dynamic>;
|
||||
return list.map((e) => e as String).toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/journal/trigger-prep — force-regenerate today's daily prep
|
||||
/// (or a specific day if [isoDate] is given).
|
||||
Future<void> triggerPrep({String? isoDate}) async {
|
||||
try {
|
||||
final body = <String, dynamic>{};
|
||||
if (isoDate != null) body['date'] = isoDate;
|
||||
await _dio.post('/api/journal/trigger-prep', data: body);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/knowledge_item.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class KnowledgeApi {
|
||||
final Dio _dio;
|
||||
const KnowledgeApi(this._dio);
|
||||
|
||||
/// Fetch a page of IDs. Returns (ids, total).
|
||||
Future<(List<int>, int)> fetchIds({
|
||||
String? noteType,
|
||||
List<String> tags = const [],
|
||||
String sort = 'modified',
|
||||
String? q,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'sort': sort,
|
||||
if (noteType != null) 'type': noteType,
|
||||
if (tags.isNotEmpty) 'tags': tags.join(','),
|
||||
if (q != null && q.isNotEmpty) 'q': q,
|
||||
};
|
||||
final response =
|
||||
await _dio.get('/api/knowledge/ids', queryParameters: params);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final ids =
|
||||
(data['ids'] as List<dynamic>).map((e) => e as int).toList();
|
||||
final total = data['total'] as int;
|
||||
return (ids, total);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch-hydrate up to 100 IDs into full items.
|
||||
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) async {
|
||||
if (ids.isEmpty) return [];
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/knowledge/batch',
|
||||
queryParameters: {'ids': ids.join(',')},
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return (data['items'] as List<dynamic>)
|
||||
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-type counts for tab labels.
|
||||
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
if (tags.isNotEmpty) 'tags': tags.join(','),
|
||||
};
|
||||
final response = await _dio.get('/api/knowledge/counts',
|
||||
queryParameters: params);
|
||||
return (response.data as Map<String, dynamic>)
|
||||
.map((k, v) => MapEntry(k, (v as num).toInt()));
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// All tags for the current type filter.
|
||||
Future<List<String>> fetchTags({String? noteType}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/knowledge/tags',
|
||||
queryParameters: {if (noteType != null) 'type': noteType},
|
||||
);
|
||||
return (response.data['tags'] as List<dynamic>)
|
||||
.map((e) => e as String)
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/milestone.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class MilestonesApi {
|
||||
final Dio _dio;
|
||||
const MilestonesApi(this._dio);
|
||||
|
||||
Future<List<Milestone>> getAll(int projectId, {String? status}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/projects/$projectId/milestones',
|
||||
queryParameters: status != null ? {'status': status} : null,
|
||||
);
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final list = data['milestones'] as List<dynamic>;
|
||||
return list
|
||||
.map((e) => Milestone.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Milestone> create(
|
||||
int projectId, {
|
||||
required String title,
|
||||
String? description,
|
||||
int orderIndex = 0,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/projects/$projectId/milestones',
|
||||
data: {
|
||||
'title': title,
|
||||
if (description != null && description.isNotEmpty)
|
||||
'description': description,
|
||||
'order_index': orderIndex,
|
||||
},
|
||||
);
|
||||
return Milestone.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Milestone> update(
|
||||
int projectId,
|
||||
int milestoneId,
|
||||
Map<String, dynamic> fields,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.patch(
|
||||
'/api/projects/$projectId/milestones/$milestoneId',
|
||||
data: fields,
|
||||
);
|
||||
return Milestone.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int projectId, int milestoneId) async {
|
||||
try {
|
||||
await _dio.delete('/api/projects/$projectId/milestones/$milestoneId');
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,20 @@ class NotesApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> create(String title, String body) async {
|
||||
Future<Note> create(
|
||||
String title,
|
||||
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>);
|
||||
} on DioException catch (e) {
|
||||
@@ -39,11 +48,22 @@ class NotesApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Note> update(int id, String title, String body) async {
|
||||
Future<Note> update(
|
||||
int id,
|
||||
String title,
|
||||
String body, {
|
||||
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>);
|
||||
} on DioException catch (e) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/project.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
class ProjectsApi {
|
||||
final Dio _dio;
|
||||
const ProjectsApi(this._dio);
|
||||
|
||||
Future<List<Project>> getAll({
|
||||
String? status,
|
||||
String sort = 'updated_at',
|
||||
String order = 'desc',
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/api/projects',
|
||||
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>;
|
||||
return list
|
||||
.map((e) => Project.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> getOne(int id) async {
|
||||
try {
|
||||
final response = await _dio.get('/api/projects/$id');
|
||||
return Project.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> create({
|
||||
required String title,
|
||||
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,
|
||||
if (color != null) 'color': color,
|
||||
});
|
||||
return Project.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final response = await _dio.patch('/api/projects/$id', data: fields);
|
||||
return Project.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
try {
|
||||
await _dio.delete('/api/projects/$id');
|
||||
} on DioException catch (e) {
|
||||
throw dioToApp(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ class TasksApi {
|
||||
required TaskStatus status,
|
||||
required TaskPriority priority,
|
||||
DateTime? dueDate,
|
||||
int? projectId,
|
||||
int? parentId,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post('/api/tasks', data: {
|
||||
@@ -41,6 +43,8 @@ class TasksApi {
|
||||
'status': status.value,
|
||||
'priority': priority.value,
|
||||
'due_date': dueDate?.toIso8601String(),
|
||||
if (projectId != null) 'project_id': projectId,
|
||||
if (parentId != null) 'parent_id': parentId,
|
||||
});
|
||||
return Task.fromJson(response.data as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
@@ -48,6 +52,38 @@ 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(
|
||||
'/api/notes',
|
||||
queryParameters: {'parent_id': parentId, 'is_task': 'true', 'limit': 100},
|
||||
);
|
||||
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<Task> update(int id, Map<String, dynamic> fields) async {
|
||||
try {
|
||||
final response = await _dio.put('/api/tasks/$id', data: fields);
|
||||
|
||||
@@ -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,47 @@
|
||||
class CalendarEvent {
|
||||
final int id;
|
||||
final String title;
|
||||
final DateTime startDt;
|
||||
final DateTime? endDt;
|
||||
final bool allDay;
|
||||
final String description;
|
||||
final String location;
|
||||
final String color;
|
||||
final String? recurrence;
|
||||
final int? projectId;
|
||||
final int? reminderMinutes;
|
||||
|
||||
const CalendarEvent({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.startDt,
|
||||
this.endDt,
|
||||
required this.allDay,
|
||||
required this.description,
|
||||
required this.location,
|
||||
required this.color,
|
||||
this.recurrence,
|
||||
this.projectId,
|
||||
this.reminderMinutes,
|
||||
});
|
||||
|
||||
factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
startDt: DateTime.parse(json['start_dt'] as String).toLocal(),
|
||||
endDt: json['end_dt'] != null
|
||||
? DateTime.parse(json['end_dt'] as String).toLocal()
|
||||
: null,
|
||||
allDay: json['all_day'] as bool? ?? false,
|
||||
description: json['description'] as String? ?? '',
|
||||
location: json['location'] as String? ?? '',
|
||||
color: json['color'] as String? ?? '',
|
||||
recurrence: json['recurrence'] as String?,
|
||||
projectId: json['project_id'] as int?,
|
||||
reminderMinutes: json['reminder_minutes'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Strips time from a DateTime, returning midnight local.
|
||||
/// Used as map keys in CalendarState.eventsByDay.
|
||||
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'message.dart';
|
||||
|
||||
/// Lightweight conversation header for a journal day — just enough to drive
|
||||
/// navigation and labels. The full message list comes alongside in [JournalDay].
|
||||
class JournalConversation {
|
||||
final int id;
|
||||
final String title;
|
||||
final String conversationType;
|
||||
final String? dayDate; // YYYY-MM-DD or null
|
||||
|
||||
const JournalConversation({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.conversationType,
|
||||
this.dayDate,
|
||||
});
|
||||
|
||||
factory JournalConversation.fromJson(Map<String, dynamic> json) =>
|
||||
JournalConversation(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
conversationType:
|
||||
json['conversation_type'] as String? ?? 'journal',
|
||||
dayDate: json['day_date'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Payload returned by GET /api/journal/today and /api/journal/day/<iso>.
|
||||
/// `conversation` is null on a day with no journal content yet (rare —
|
||||
/// the today endpoint creates it on demand).
|
||||
class JournalDay {
|
||||
final String dayDate;
|
||||
final JournalConversation? conversation;
|
||||
final List<Message> messages;
|
||||
|
||||
const JournalDay({
|
||||
required this.dayDate,
|
||||
required this.conversation,
|
||||
required this.messages,
|
||||
});
|
||||
|
||||
factory JournalDay.fromJson(Map<String, dynamic> json) {
|
||||
final convRaw = json['conversation'] as Map<String, dynamic>?;
|
||||
final rawMessages = json['messages'] as List<dynamic>? ?? [];
|
||||
return JournalDay(
|
||||
dayDate: json['day_date'] as String,
|
||||
conversation:
|
||||
convRaw == null ? null : JournalConversation.fromJson(convRaw),
|
||||
messages: rawMessages
|
||||
.map((e) => Message.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
JournalDay copyWith({List<Message>? messages}) => JournalDay(
|
||||
dayDate: dayDate,
|
||||
conversation: conversation,
|
||||
messages: messages ?? this.messages,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'task.dart';
|
||||
|
||||
class KnowledgeItem {
|
||||
final int id;
|
||||
final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task'
|
||||
final String title;
|
||||
final String body;
|
||||
final List<String> tags;
|
||||
final int? projectId;
|
||||
final int? milestoneId;
|
||||
final int? parentId;
|
||||
// Task-only fields (null for non-tasks)
|
||||
final String? status; // 'todo' | 'in_progress' | 'done' | 'cancelled'
|
||||
final String? priority; // 'low' | 'normal' | 'high'
|
||||
final String? dueDate;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const KnowledgeItem({
|
||||
required this.id,
|
||||
required this.noteType,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.tags,
|
||||
this.projectId,
|
||||
this.milestoneId,
|
||||
this.parentId,
|
||||
this.status,
|
||||
this.priority,
|
||||
this.dueDate,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory KnowledgeItem.fromTask(Task task) => KnowledgeItem(
|
||||
id: task.id,
|
||||
noteType: 'task',
|
||||
title: task.title,
|
||||
body: task.description ?? '',
|
||||
tags: const [],
|
||||
projectId: task.projectId,
|
||||
milestoneId: task.milestoneId,
|
||||
parentId: task.parentId,
|
||||
status: task.status.value,
|
||||
priority: task.priority.value,
|
||||
dueDate: task.dueDate?.toIso8601String(),
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
);
|
||||
|
||||
factory KnowledgeItem.fromJson(Map<String, dynamic> json) => KnowledgeItem(
|
||||
id: json['id'] as int,
|
||||
noteType: json['note_type'] as String? ?? 'note',
|
||||
title: json['title'] as String? ?? '',
|
||||
body: (json['snippet'] ?? json['body']) as String? ?? '',
|
||||
tags: (json['tags'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
projectId: json['project_id'] as int?,
|
||||
milestoneId: json['milestone_id'] as int?,
|
||||
parentId: json['parent_id'] as int?,
|
||||
status: json['status'] as String?,
|
||||
priority: json['priority'] as String?,
|
||||
dueDate: json['due_date'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
@@ -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,41 @@
|
||||
class Milestone {
|
||||
final int id;
|
||||
final int projectId;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String status; // active | completed | archived
|
||||
final int orderIndex;
|
||||
final int total;
|
||||
final int completed;
|
||||
final double pct;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Milestone({
|
||||
required this.id,
|
||||
required this.projectId,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.status,
|
||||
required this.orderIndex,
|
||||
required this.total,
|
||||
required this.completed,
|
||||
required this.pct,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory Milestone.fromJson(Map<String, dynamic> json) => Milestone(
|
||||
id: json['id'] as int,
|
||||
projectId: json['project_id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
status: json['status'] as String? ?? 'active',
|
||||
orderIndex: json['order_index'] as int? ?? 0,
|
||||
total: json['total'] as int? ?? 0,
|
||||
completed: json['completed'] as int? ?? 0,
|
||||
pct: (json['pct'] as num?)?.toDouble() ?? 0.0,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,10 @@ class Note {
|
||||
final int id;
|
||||
final String title;
|
||||
final String body;
|
||||
final List<String> tags;
|
||||
final String noteType;
|
||||
final int? projectId;
|
||||
final int? milestoneId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
@@ -9,6 +13,10 @@ class Note {
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.tags,
|
||||
this.noteType = 'note',
|
||||
this.projectId,
|
||||
this.milestoneId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@@ -17,6 +25,13 @@ class Note {
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
body: json['body'] as String? ?? '',
|
||||
tags: (json['tags'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
noteType: json['note_type'] as String? ?? 'note',
|
||||
projectId: json['project_id'] as int?,
|
||||
milestoneId: json['milestone_id'] as int?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
@@ -24,13 +39,35 @@ class Note {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'tags': tags,
|
||||
'note_type': noteType,
|
||||
'project_id': projectId,
|
||||
'milestone_id': milestoneId,
|
||||
};
|
||||
|
||||
Note copyWith({String? title, String? body}) => Note(
|
||||
Note copyWith({
|
||||
String? title,
|
||||
String? body,
|
||||
List<String>? tags,
|
||||
String? noteType,
|
||||
Object? projectId = _undefined,
|
||||
Object? milestoneId = _undefined,
|
||||
}) =>
|
||||
Note(
|
||||
id: id,
|
||||
title: title ?? this.title,
|
||||
body: body ?? this.body,
|
||||
tags: tags ?? this.tags,
|
||||
noteType: noteType ?? this.noteType,
|
||||
projectId: identical(projectId, _undefined)
|
||||
? this.projectId
|
||||
: projectId as int?,
|
||||
milestoneId: identical(milestoneId, _undefined)
|
||||
? this.milestoneId
|
||||
: milestoneId as int?,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
|
||||
static const _undefined = Object();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
class Project {
|
||||
final int id;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String? goal;
|
||||
final String status; // active | completed | archived
|
||||
final String? color;
|
||||
final String? autoSummary;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Project({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.goal,
|
||||
required this.status,
|
||||
this.color,
|
||||
this.autoSummary,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory Project.fromJson(Map<String, dynamic> json) => Project(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
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),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'goal': goal,
|
||||
'status': status,
|
||||
'color': color,
|
||||
'auto_summary': autoSummary,
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,9 @@ class Task {
|
||||
final TaskStatus status;
|
||||
final TaskPriority priority;
|
||||
final DateTime? dueDate;
|
||||
final int? projectId;
|
||||
final int? milestoneId;
|
||||
final int? parentId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
@@ -62,6 +65,9 @@ class Task {
|
||||
required this.status,
|
||||
required this.priority,
|
||||
this.dueDate,
|
||||
this.projectId,
|
||||
this.milestoneId,
|
||||
this.parentId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@@ -75,6 +81,9 @@ class Task {
|
||||
dueDate: json['due_date'] != null
|
||||
? DateTime.parse(json['due_date'] as String)
|
||||
: null,
|
||||
projectId: json['project_id'] as int?,
|
||||
milestoneId: json['milestone_id'] as int?,
|
||||
parentId: json['parent_id'] as int?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
@@ -85,6 +94,9 @@ class Task {
|
||||
'status': status.value,
|
||||
'priority': priority.value,
|
||||
'due_date': dueDate?.toIso8601String(),
|
||||
'project_id': projectId,
|
||||
'milestone_id': milestoneId,
|
||||
'parent_id': parentId,
|
||||
};
|
||||
|
||||
Task copyWith({
|
||||
@@ -93,6 +105,9 @@ class Task {
|
||||
TaskStatus? status,
|
||||
TaskPriority? priority,
|
||||
DateTime? dueDate,
|
||||
Object? projectId = _undefined,
|
||||
Object? milestoneId = _undefined,
|
||||
Object? parentId = _undefined,
|
||||
}) =>
|
||||
Task(
|
||||
id: id,
|
||||
@@ -101,7 +116,18 @@ class Task {
|
||||
status: status ?? this.status,
|
||||
priority: priority ?? this.priority,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
projectId: identical(projectId, _undefined)
|
||||
? this.projectId
|
||||
: projectId as int?,
|
||||
milestoneId: identical(milestoneId, _undefined)
|
||||
? this.milestoneId
|
||||
: milestoneId as int?,
|
||||
parentId: identical(parentId, _undefined)
|
||||
? this.parentId
|
||||
: parentId as int?,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
|
||||
static const _undefined = Object();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import '../api/milestones_api.dart';
|
||||
import '../models/milestone.dart';
|
||||
|
||||
class MilestonesRepository {
|
||||
final MilestonesApi _api;
|
||||
const MilestonesRepository(this._api);
|
||||
|
||||
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
|
||||
_api.getAll(projectId, status: status);
|
||||
|
||||
Future<Milestone> create(
|
||||
int projectId, {
|
||||
required String title,
|
||||
String? description,
|
||||
int orderIndex = 0,
|
||||
}) =>
|
||||
_api.create(projectId,
|
||||
title: title, description: description, orderIndex: orderIndex);
|
||||
|
||||
Future<Milestone> update(
|
||||
int projectId, int milestoneId, Map<String, dynamic> fields) =>
|
||||
_api.update(projectId, milestoneId, fields);
|
||||
|
||||
Future<void> delete(int projectId, int milestoneId) =>
|
||||
_api.delete(projectId, milestoneId);
|
||||
}
|
||||
@@ -7,9 +7,31 @@ class NotesRepository {
|
||||
|
||||
Future<List<Note>> getAll() => _api.getAll();
|
||||
Future<Note> getOne(int id) => _api.getOne(id);
|
||||
Future<Note> create(String title, String body) =>
|
||||
_api.create(title, body);
|
||||
Future<Note> update(int id, String title, String body) =>
|
||||
_api.update(id, title, body);
|
||||
|
||||
Future<Note> create(
|
||||
String title,
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
String noteType = 'note',
|
||||
}) =>
|
||||
_api.create(title, body,
|
||||
tags: tags, projectId: projectId, noteType: noteType);
|
||||
|
||||
Future<Note> update(
|
||||
int id,
|
||||
String title,
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
bool clearProject = false,
|
||||
String noteType = 'note',
|
||||
}) =>
|
||||
_api.update(id, title, body,
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
clearProject: clearProject,
|
||||
noteType: noteType);
|
||||
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import '../api/projects_api.dart';
|
||||
import '../models/project.dart';
|
||||
|
||||
class ProjectsRepository {
|
||||
final ProjectsApi _api;
|
||||
const ProjectsRepository(this._api);
|
||||
|
||||
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,
|
||||
status: status);
|
||||
Future<Project> update(int id, Map<String, dynamic> fields) =>
|
||||
_api.update(id, fields);
|
||||
Future<void> delete(int id) => _api.delete(id);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ class TasksRepository {
|
||||
required TaskStatus status,
|
||||
required TaskPriority priority,
|
||||
DateTime? dueDate,
|
||||
int? projectId,
|
||||
int? parentId,
|
||||
}) =>
|
||||
_api.create(
|
||||
title: title,
|
||||
@@ -21,8 +23,13 @@ class TasksRepository {
|
||||
status: status,
|
||||
priority: priority,
|
||||
dueDate: dueDate,
|
||||
projectId: projectId,
|
||||
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) =>
|
||||
_api.update(id, 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);
|
||||
}
|
||||
@@ -5,12 +5,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../data/api/api_client.dart';
|
||||
import '../data/api/auth_api.dart';
|
||||
import '../data/api/chat_api.dart';
|
||||
import '../data/api/journal_api.dart';
|
||||
import '../data/api/knowledge_api.dart';
|
||||
import '../data/api/voice_api.dart';
|
||||
import '../data/api/milestones_api.dart';
|
||||
import '../data/api/events_api.dart';
|
||||
import '../data/api/notes_api.dart';
|
||||
import '../data/api/projects_api.dart';
|
||||
import '../data/api/quick_capture_api.dart';
|
||||
import '../data/api/settings_api.dart';
|
||||
import '../data/api/tasks_api.dart';
|
||||
import '../data/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';
|
||||
import '../data/repositories/tasks_repository.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
@@ -45,6 +56,10 @@ final quickCaptureApiProvider = Provider<QuickCaptureApi>((ref) {
|
||||
return QuickCaptureApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final projectsApiProvider = Provider<ProjectsApi>((ref) {
|
||||
return ProjectsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
return AuthRepository(ref.watch(authApiProvider));
|
||||
});
|
||||
@@ -60,3 +75,43 @@ final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
|
||||
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
|
||||
return ChatRepository(ref.watch(chatApiProvider));
|
||||
});
|
||||
|
||||
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
|
||||
return ProjectsRepository(ref.watch(projectsApiProvider));
|
||||
});
|
||||
|
||||
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
|
||||
return MilestonesApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
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 journalApiProvider = Provider<JournalApi>((ref) {
|
||||
return JournalApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
||||
return SettingsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final voiceApiProvider = Provider<VoiceApi>((ref) {
|
||||
return VoiceApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
|
||||
return VoiceRepository(ref.watch(voiceApiProvider));
|
||||
});
|
||||
|
||||
final eventsApiProvider = Provider<EventsApi>((ref) {
|
||||
return EventsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
@@ -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,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,212 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/api/chat_api.dart';
|
||||
import '../data/models/journal_day.dart';
|
||||
import '../data/models/message.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
/// Drives the loading indicator in JournalScreen's reply area.
|
||||
final isJournalStreamingProvider =
|
||||
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
|
||||
|
||||
class _BoolNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => false;
|
||||
}
|
||||
|
||||
final journalProvider =
|
||||
AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
|
||||
|
||||
class JournalNotifier extends AsyncNotifier<JournalDay> {
|
||||
@override
|
||||
Future<JournalDay> build() async {
|
||||
return ref.read(journalApiProvider).getToday();
|
||||
}
|
||||
|
||||
/// Silently fetch today's journal and patch state without triggering
|
||||
/// AsyncLoading — existing content stays visible while the fetch is in flight.
|
||||
Future<void> silentRefresh() async {
|
||||
final current = state.value;
|
||||
if (current == null) return;
|
||||
try {
|
||||
final fresh = await ref.read(journalApiProvider).getToday();
|
||||
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
|
||||
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
|
||||
if (fresh.messages.length != current.messages.length ||
|
||||
newLast?.content != curLast?.content) {
|
||||
state = AsyncData(fresh);
|
||||
}
|
||||
} catch (_) {
|
||||
// Network hiccup — silently ignore, keep existing content.
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-regenerate today's daily prep then reload.
|
||||
Future<void> regeneratePrep() async {
|
||||
await ref.read(journalApiProvider).triggerPrep();
|
||||
ref.invalidateSelf();
|
||||
await future;
|
||||
}
|
||||
|
||||
/// Re-fetch today's journal and unfreeze a stuck streaming state if the
|
||||
/// server-side message is already complete.
|
||||
///
|
||||
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
|
||||
/// socket dies silently the send loop never observes close and
|
||||
/// [isJournalStreamingProvider] stays stuck true. This is the manual
|
||||
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and
|
||||
/// the lifecycle-resume hook.
|
||||
Future<void> refreshMessages() async {
|
||||
final current = state.value;
|
||||
if (current == null) {
|
||||
ref.invalidateSelf();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final fresh = await ref.read(journalApiProvider).getToday();
|
||||
state = AsyncData(fresh);
|
||||
final messages = fresh.messages;
|
||||
Message? lastAssistant;
|
||||
for (var i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role == MessageRole.assistant) {
|
||||
lastAssistant = messages[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastAssistant != null && lastAssistant.status != 'generating') {
|
||||
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||
}
|
||||
} catch (_) {
|
||||
// Network hiccup — keep existing state; user can retry.
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a reply to today's journal conversation.
|
||||
///
|
||||
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
|
||||
/// stall-watchdog pattern:
|
||||
/// 1. Optimistic UI update
|
||||
/// 2. POST message to chat endpoint
|
||||
/// 3. SSE stream with per-event timeout (stall watchdog)
|
||||
/// 4. Poll until complete
|
||||
Future<void> sendReply(String content) async {
|
||||
final day = state.value;
|
||||
if (day == null) return;
|
||||
final conv = day.conversation;
|
||||
if (conv == null) return;
|
||||
final convId = conv.id;
|
||||
final chatApi = ref.read(chatApiProvider);
|
||||
|
||||
final previous = day.messages;
|
||||
final userMsg = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.user,
|
||||
content: content,
|
||||
);
|
||||
final placeholder = Message(
|
||||
conversationId: convId,
|
||||
role: MessageRole.assistant,
|
||||
content: '',
|
||||
status: 'generating',
|
||||
);
|
||||
state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder]));
|
||||
ref.read(isJournalStreamingProvider.notifier).state = true;
|
||||
|
||||
try {
|
||||
await chatApi.sendMessage(convId, content);
|
||||
} catch (e) {
|
||||
state = AsyncData(day.copyWith(messages: previous));
|
||||
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
|
||||
await _pollUntilComplete(convId, streamedContent);
|
||||
}
|
||||
|
||||
/// Consume an SSE stream into the current journal day state.
|
||||
///
|
||||
/// Uses a StreamIterator with a per-event timeout as a stall watchdog —
|
||||
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
|
||||
/// Mobile networks occasionally drop SSE sockets silently. If no event
|
||||
/// arrives within the watchdog window we bail out and let polling
|
||||
/// reconcile state from the server.
|
||||
///
|
||||
/// Returns whether any text content was actually streamed.
|
||||
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
|
||||
const stallTimeout = Duration(seconds: 45);
|
||||
bool streamedContent = false;
|
||||
final iter = StreamIterator(stream);
|
||||
try {
|
||||
while (await iter.moveNext().timeout(stallTimeout)) {
|
||||
final event = iter.current;
|
||||
final current = state.value;
|
||||
if (current == null) break;
|
||||
final msgs = current.messages;
|
||||
if (msgs.isEmpty) continue;
|
||||
if (event is ChatTextChunk) {
|
||||
streamedContent = true;
|
||||
final updated =
|
||||
msgs.last.copyWith(content: msgs.last.content + event.text);
|
||||
state = AsyncData(current.copyWith(
|
||||
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
|
||||
} else if (event is ChatToolCall) {
|
||||
final last = msgs.last;
|
||||
if (last.role != MessageRole.assistant) continue;
|
||||
final nextCalls = [...?last.toolCalls, event.toolCall];
|
||||
final updated = last.copyWith(toolCalls: nextCalls);
|
||||
state = AsyncData(current.copyWith(
|
||||
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
// Stall watchdog — no SSE event for stallTimeout. Fall through to
|
||||
// polling so the UI eventually unfreezes even if the socket is dead.
|
||||
} catch (_) {
|
||||
// SSE failed — fall through to polling.
|
||||
} finally {
|
||||
await iter.cancel();
|
||||
}
|
||||
return streamedContent;
|
||||
}
|
||||
|
||||
/// Poll today's journal until the last assistant row is complete. Always
|
||||
/// clears [isJournalStreamingProvider] at the end so the input can't stay
|
||||
/// locked.
|
||||
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
|
||||
final journalApi = ref.read(journalApiProvider);
|
||||
try {
|
||||
for (var attempt = 0; attempt < 20; attempt++) {
|
||||
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
||||
final fresh = await journalApi.getToday();
|
||||
final freshMsgs = fresh.messages;
|
||||
final done = freshMsgs.any(
|
||||
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
||||
);
|
||||
final hasContent = freshMsgs.any(
|
||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||
);
|
||||
final current = state.value;
|
||||
if (current != null && (!streamedContent || done || hasContent)) {
|
||||
state = AsyncData(current.copyWith(messages: freshMsgs));
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} catch (_) {
|
||||
final current = state.value;
|
||||
if (current != null) {
|
||||
final msgs = current.messages;
|
||||
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||
state = AsyncData(current.copyWith(messages: [
|
||||
...msgs.sublist(0, msgs.length - 1),
|
||||
msgs.last.copyWith(status: 'complete'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ref.read(isJournalStreamingProvider.notifier).state = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/knowledge_item.dart';
|
||||
import '../data/repositories/knowledge_repository.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
export '../data/models/knowledge_item.dart';
|
||||
|
||||
/// Immutable state for the Knowledge screen's two-tier paginated feed.
|
||||
class KnowledgeState {
|
||||
final List<int> ids;
|
||||
final Map<int, KnowledgeItem> items;
|
||||
final int totalIds;
|
||||
final bool isLoadingIds;
|
||||
final bool isLoadingBatch;
|
||||
final bool hasMore;
|
||||
final String? noteType; // null = All
|
||||
final List<String> activeTags;
|
||||
final String? searchQuery;
|
||||
final Map<String, int> counts;
|
||||
final List<String> availableTags;
|
||||
final String? error;
|
||||
|
||||
const KnowledgeState({
|
||||
this.ids = const [],
|
||||
this.items = const {},
|
||||
this.totalIds = 0,
|
||||
this.isLoadingIds = false,
|
||||
this.isLoadingBatch = false,
|
||||
this.hasMore = false,
|
||||
this.noteType,
|
||||
this.activeTags = const [],
|
||||
this.searchQuery,
|
||||
this.counts = const {},
|
||||
this.availableTags = const [],
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Items in server-defined ID order, only those already hydrated.
|
||||
List<KnowledgeItem> get orderedItems =>
|
||||
ids.where(items.containsKey).map((id) => items[id]!).toList();
|
||||
|
||||
/// IDs that have been fetched but not yet hydrated.
|
||||
List<int> get unhydratedIds =>
|
||||
ids.where((id) => !items.containsKey(id)).toList();
|
||||
|
||||
KnowledgeState copyWith({
|
||||
List<int>? ids,
|
||||
Map<int, KnowledgeItem>? items,
|
||||
int? totalIds,
|
||||
bool? isLoadingIds,
|
||||
bool? isLoadingBatch,
|
||||
bool? hasMore,
|
||||
Object? noteType = _keep,
|
||||
List<String>? activeTags,
|
||||
Object? searchQuery = _keep,
|
||||
Map<String, int>? counts,
|
||||
List<String>? availableTags,
|
||||
Object? error = _keep,
|
||||
}) =>
|
||||
KnowledgeState(
|
||||
ids: ids ?? this.ids,
|
||||
items: items ?? this.items,
|
||||
totalIds: totalIds ?? this.totalIds,
|
||||
isLoadingIds: isLoadingIds ?? this.isLoadingIds,
|
||||
isLoadingBatch: isLoadingBatch ?? this.isLoadingBatch,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
noteType:
|
||||
identical(noteType, _keep) ? this.noteType : noteType as String?,
|
||||
activeTags: activeTags ?? this.activeTags,
|
||||
searchQuery: identical(searchQuery, _keep)
|
||||
? this.searchQuery
|
||||
: searchQuery as String?,
|
||||
counts: counts ?? this.counts,
|
||||
availableTags: availableTags ?? this.availableTags,
|
||||
error: identical(error, _keep) ? this.error : error as String?,
|
||||
);
|
||||
|
||||
static const _keep = Object();
|
||||
}
|
||||
|
||||
class KnowledgeNotifier extends Notifier<KnowledgeState> {
|
||||
@override
|
||||
KnowledgeState build() => const KnowledgeState();
|
||||
|
||||
KnowledgeRepository get _repo => ref.read(knowledgeRepositoryProvider);
|
||||
|
||||
// ── Filter setters — each resets and re-fetches ──────────────────────────
|
||||
|
||||
Future<void> setTypeFilter(String? noteType) async {
|
||||
state = KnowledgeState(noteType: noteType, activeTags: state.activeTags);
|
||||
await _fetchFromScratch();
|
||||
}
|
||||
|
||||
Future<void> toggleTag(String tag) async {
|
||||
final tags = state.activeTags.contains(tag)
|
||||
? state.activeTags.where((t) => t != tag).toList()
|
||||
: [...state.activeTags, tag];
|
||||
state = KnowledgeState(
|
||||
noteType: state.noteType,
|
||||
activeTags: tags,
|
||||
searchQuery: state.searchQuery,
|
||||
);
|
||||
await _fetchFromScratch();
|
||||
}
|
||||
|
||||
Future<void> setSearch(String? q) async {
|
||||
final query = (q?.trim().isEmpty ?? true) ? null : q?.trim();
|
||||
state = KnowledgeState(
|
||||
noteType: state.noteType,
|
||||
activeTags: state.activeTags,
|
||||
searchQuery: query,
|
||||
);
|
||||
await _fetchFromScratch();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
// Keep current items visible during re-fetch (no flicker).
|
||||
try {
|
||||
if (state.noteType == 'task') {
|
||||
final tasks = await ref.read(tasksApiProvider).getAll();
|
||||
final items = {
|
||||
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
|
||||
};
|
||||
state = state.copyWith(
|
||||
ids: tasks.map((t) => t.id).toList(),
|
||||
items: items,
|
||||
totalIds: tasks.length,
|
||||
hasMore: false,
|
||||
);
|
||||
await _loadCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
final (ids, total) = await _repo.fetchIds(
|
||||
noteType: state.noteType,
|
||||
tags: state.activeTags,
|
||||
q: state.searchQuery,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
);
|
||||
// Hydrate the new IDs
|
||||
final batch = await _repo.fetchBatch(ids);
|
||||
final freshItems = {for (final item in batch) item.id: item};
|
||||
state = state.copyWith(
|
||||
ids: ids,
|
||||
items: freshItems,
|
||||
totalIds: total,
|
||||
hasMore: ids.length < total,
|
||||
);
|
||||
await Future.wait([_loadCounts(), _loadTags()]);
|
||||
} catch (_) {
|
||||
// Silent — stale data is better than an error on refresh
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scroll-triggered loaders ─────────────────────────────────────────────
|
||||
|
||||
/// Hydrate the next 12 un-hydrated IDs. Call when approaching scroll end.
|
||||
Future<void> hydrateNext() async {
|
||||
if (state.isLoadingBatch) return;
|
||||
final toFetch = state.unhydratedIds.take(12).toList();
|
||||
if (toFetch.isEmpty) {
|
||||
// All fetched IDs are hydrated — try loading more IDs.
|
||||
if (state.hasMore && !state.isLoadingIds) await _loadMoreIds();
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isLoadingBatch: true);
|
||||
try {
|
||||
final fetched = await _repo.fetchBatch(toFetch);
|
||||
final updated = Map<int, KnowledgeItem>.from(state.items);
|
||||
for (final item in fetched) {
|
||||
updated[item.id] = item;
|
||||
}
|
||||
state = state.copyWith(items: updated, isLoadingBatch: false);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingBatch: false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _fetchFromScratch() async {
|
||||
state = state.copyWith(isLoadingIds: true, error: null);
|
||||
try {
|
||||
if (state.noteType == 'task') {
|
||||
// Tasks live under /api/tasks — fetch all and convert directly.
|
||||
final tasks = await ref.read(tasksApiProvider).getAll();
|
||||
final items = {
|
||||
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
|
||||
};
|
||||
state = state.copyWith(
|
||||
ids: tasks.map((t) => t.id).toList(),
|
||||
items: items,
|
||||
totalIds: tasks.length,
|
||||
isLoadingIds: false,
|
||||
hasMore: false,
|
||||
);
|
||||
await _loadCounts();
|
||||
return;
|
||||
}
|
||||
|
||||
final (ids, total) = await _repo.fetchIds(
|
||||
noteType: state.noteType,
|
||||
tags: state.activeTags,
|
||||
q: state.searchQuery,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
);
|
||||
state = state.copyWith(
|
||||
ids: ids,
|
||||
items: {},
|
||||
totalIds: total,
|
||||
isLoadingIds: false,
|
||||
hasMore: ids.length < total,
|
||||
);
|
||||
// Load counts and tags in parallel with the first batch hydration.
|
||||
await Future.wait([
|
||||
hydrateNext(),
|
||||
_loadCounts(),
|
||||
_loadTags(),
|
||||
]);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoadingIds: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMoreIds() async {
|
||||
if (!state.hasMore || state.isLoadingIds) return;
|
||||
state = state.copyWith(isLoadingIds: true);
|
||||
try {
|
||||
final (newIds, total) = await _repo.fetchIds(
|
||||
noteType: state.noteType,
|
||||
tags: state.activeTags,
|
||||
q: state.searchQuery,
|
||||
limit: 50,
|
||||
offset: state.ids.length,
|
||||
);
|
||||
final combined = [...state.ids, ...newIds];
|
||||
state = state.copyWith(
|
||||
ids: combined,
|
||||
totalIds: total,
|
||||
isLoadingIds: false,
|
||||
hasMore: combined.length < total,
|
||||
);
|
||||
// Hydrate the newly fetched IDs immediately — the user is
|
||||
// already at the scroll bottom so _onScroll won't re-fire.
|
||||
await hydrateNext();
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingIds: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCounts() async {
|
||||
try {
|
||||
final counts = await _repo.fetchCounts(tags: state.activeTags);
|
||||
state = state.copyWith(counts: counts);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _loadTags() async {
|
||||
try {
|
||||
final tags = await _repo.fetchTags(noteType: state.noteType);
|
||||
state = state.copyWith(availableTags: tags);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
final knowledgeProvider =
|
||||
NotifierProvider<KnowledgeNotifier, KnowledgeState>(KnowledgeNotifier.new);
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/milestone.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
/// Fetches active milestones for a given project ID.
|
||||
/// Keyed by projectId so each project gets its own cached list.
|
||||
final projectMilestonesProvider =
|
||||
FutureProvider.family<List<Milestone>, int>((ref, projectId) {
|
||||
return ref.watch(milestonesRepositoryProvider).getAll(projectId);
|
||||
});
|
||||
@@ -12,18 +12,43 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
return ref.watch(notesRepositoryProvider).getAll();
|
||||
}
|
||||
|
||||
Future<Note> create(String title, String body) async {
|
||||
final note =
|
||||
await ref.read(notesRepositoryProvider).create(title, body);
|
||||
state = AsyncData([...state.valueOrNull ?? [], note]);
|
||||
Future<Note> create(
|
||||
String title,
|
||||
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,
|
||||
noteType: noteType,
|
||||
);
|
||||
state = AsyncData([...state.value ?? [], note]);
|
||||
return note;
|
||||
}
|
||||
|
||||
Future<Note> updateNote(int id, String title, String body) async {
|
||||
final updated =
|
||||
await ref.read(notesRepositoryProvider).update(id, title, body);
|
||||
Future<Note> updateNote(
|
||||
int id,
|
||||
String title,
|
||||
String body, {
|
||||
List<String> tags = const [],
|
||||
int? projectId,
|
||||
bool clearProject = false,
|
||||
String noteType = 'note',
|
||||
}) async {
|
||||
final updated = await ref.read(notesRepositoryProvider).update(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tags: tags,
|
||||
projectId: projectId,
|
||||
clearProject: clearProject,
|
||||
noteType: noteType,
|
||||
);
|
||||
state = AsyncData([
|
||||
for (final n in state.valueOrNull ?? [])
|
||||
for (final n in state.value ?? [])
|
||||
if (n.id == id) updated else n,
|
||||
]);
|
||||
return updated;
|
||||
@@ -32,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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/models/project.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
final projectsProvider =
|
||||
AsyncNotifierProvider<ProjectsNotifier, List<Project>>(
|
||||
ProjectsNotifier.new);
|
||||
|
||||
class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
@override
|
||||
Future<List<Project>> build() async {
|
||||
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({
|
||||
required String title,
|
||||
String? description,
|
||||
String? goal,
|
||||
String? color,
|
||||
}) async {
|
||||
final project = await ref.read(projectsRepositoryProvider).create(
|
||||
title: title,
|
||||
description: description,
|
||||
goal: goal,
|
||||
color: color,
|
||||
);
|
||||
state = AsyncData([...state.value ?? [], project]);
|
||||
return project;
|
||||
}
|
||||
|
||||
Future<Project> updateProject(int id, Map<String, dynamic> fields) async {
|
||||
final updated =
|
||||
await ref.read(projectsRepositoryProvider).update(id, fields);
|
||||
state = AsyncData([
|
||||
for (final p in state.value ?? [])
|
||||
if (p.id == id) updated else p,
|
||||
]);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(projectsRepositoryProvider).delete(id);
|
||||
state = AsyncData([
|
||||
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,47 +39,93 @@ 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));
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = AsyncData(await ref.read(settingsApiProvider).getAll());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,29 @@ 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,
|
||||
required TaskStatus status,
|
||||
required TaskPriority priority,
|
||||
DateTime? dueDate,
|
||||
int? projectId,
|
||||
}) async {
|
||||
final task = await ref.read(tasksRepositoryProvider).create(
|
||||
title: title,
|
||||
@@ -25,15 +36,16 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
status: status,
|
||||
priority: priority,
|
||||
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;
|
||||
@@ -42,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,18 +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();
|
||||
final currentVersion = packageInfo.version;
|
||||
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';
|
||||
@@ -67,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;
|
||||
}
|
||||
}
|
||||
@@ -98,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!,
|
||||
@@ -117,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) {
|
||||
@@ -135,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.journal);
|
||||
} 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.journal);
|
||||
},
|
||||
),
|
||||
));
|
||||
|
||||
@@ -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/flutter_markdown.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,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../data/models/journal_day.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
|
||||
class JournalHistoryScreen extends ConsumerWidget {
|
||||
const JournalHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final daysAsync = ref.watch(_journalDaysProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Past Days')),
|
||||
body: daysAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) =>
|
||||
const Center(child: Text('Could not load journal history.')),
|
||||
data: (days) {
|
||||
if (days.isEmpty) {
|
||||
return const Center(child: Text('No past days.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: days.length,
|
||||
itemBuilder: (context, i) {
|
||||
final isoDate = days[i];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.menu_book_outlined),
|
||||
title: Text(isoDate),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => _JournalDayDetailScreen(isoDate: isoDate),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JournalDayDetailScreen extends ConsumerWidget {
|
||||
final String isoDate;
|
||||
const _JournalDayDetailScreen({required this.isoDate});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final dayAsync = ref.watch(_journalDayProvider(isoDate));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(isoDate)),
|
||||
body: dayAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) =>
|
||||
const Center(child: Text('Could not load that day.')),
|
||||
data: (day) {
|
||||
if (day.messages.isEmpty) {
|
||||
return const Center(child: Text('No messages.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
itemCount: day.messages.length,
|
||||
itemBuilder: (_, i) => ChatMessageBubble(message: day.messages[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private providers (scoped to this file) ──────────────────────────────────
|
||||
|
||||
final _journalDaysProvider = FutureProvider<List<String>>((ref) async {
|
||||
return ref.watch(journalApiProvider).getDays();
|
||||
});
|
||||
|
||||
final _journalDayProvider =
|
||||
FutureProvider.family<JournalDay, String>((ref, isoDate) async {
|
||||
return ref.watch(journalApiProvider).getDay(isoDate);
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
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/journal_provider.dart';
|
||||
import '../../providers/voice_provider.dart';
|
||||
import '../../widgets/chat_message_bubble.dart';
|
||||
import '../../widgets/voice_mic_button.dart';
|
||||
import '../../widgets/weather_card.dart';
|
||||
import 'journal_history_screen.dart';
|
||||
|
||||
class JournalScreen extends ConsumerStatefulWidget {
|
||||
const JournalScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<JournalScreen> createState() => _JournalScreenState();
|
||||
}
|
||||
|
||||
class _JournalScreenState extends ConsumerState<JournalScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _refreshing = false;
|
||||
|
||||
Timer? _pollTimer;
|
||||
bool _appInForeground = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_pollTimer =
|
||||
Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final wasBackground = !_appInForeground;
|
||||
_appInForeground = state == AppLifecycleState.resumed;
|
||||
if (_appInForeground && wasBackground && mounted) {
|
||||
ref.read(journalProvider.notifier).refreshMessages();
|
||||
}
|
||||
}
|
||||
|
||||
void _pollSilently() {
|
||||
if (!_appInForeground || !mounted) return;
|
||||
final isStreaming = ref.read(isJournalStreamingProvider);
|
||||
if (isStreaming) return;
|
||||
ref.read(journalProvider.notifier).silentRefresh();
|
||||
}
|
||||
|
||||
Future<void> _pullToRefresh() async {
|
||||
try {
|
||||
await ref.read(journalProvider.notifier).refreshMessages();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not refresh.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendReply() async {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_controller.clear();
|
||||
try {
|
||||
await ref.read(journalProvider.notifier).sendReply(text);
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to send reply.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleVoiceMode() async {
|
||||
final voice = ref.read(voiceProvider);
|
||||
if (voice.voiceModeActive) {
|
||||
ref.read(voiceProvider.notifier).exitVoiceMode();
|
||||
return;
|
||||
}
|
||||
await ref.read(voiceProvider.notifier).enterVoiceMode(
|
||||
onTranscript: (transcript) async {
|
||||
await ref.read(journalProvider.notifier).sendReply(transcript);
|
||||
},
|
||||
enableTts: true,
|
||||
onError: (msg) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
await ref.read(journalProvider.notifier).regeneratePrep();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not regenerate prep.')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final journalAsync = ref.watch(journalProvider);
|
||||
final isStreaming = ref.watch(isJournalStreamingProvider);
|
||||
final voiceState = ref.watch(voiceProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
ref.listen(journalProvider, (prev, next) => _scrollToBottom());
|
||||
|
||||
// Feed streaming assistant content to VoiceNotifier for TTS.
|
||||
ref.listen(journalProvider, (prev, next) {
|
||||
if (!voiceState.voiceModeActive) return;
|
||||
final day = next.value;
|
||||
if (day == null || day.messages.isEmpty) return;
|
||||
final last = day.messages.last;
|
||||
if (last.role != MessageRole.assistant) return;
|
||||
final isComplete = last.status != 'generating';
|
||||
ref
|
||||
.read(voiceProvider.notifier)
|
||||
.feedContent(last.content, isComplete: isComplete);
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Journal', style: Theme.of(context).textTheme.titleLarge),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_refreshing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_outlined),
|
||||
tooltip: 'Regenerate prep',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'history') {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => const JournalHistoryScreen(),
|
||||
));
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => const [
|
||||
PopupMenuItem(
|
||||
value: 'history',
|
||||
child: Text('Past days'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: journalAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("Could not load today's journal."),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(journalProvider),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (day) {
|
||||
final isWide = MediaQuery.of(context).size.width >= 600;
|
||||
Widget body = Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _pullToRefresh,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
if (day.messages.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'No prep yet today.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: _refresh,
|
||||
child: const Text('Generate now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: day.messages.length,
|
||||
itemBuilder: (_, i) =>
|
||||
_JournalMessageItem(message: day.messages[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isStreaming)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
color: scheme.primary,
|
||||
),
|
||||
if (voiceState.voiceModeActive)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 6),
|
||||
child: const Text(
|
||||
'🎤 Listening… tap mic to exit voice mode',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFFF87171),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: voiceState.voiceModeActive
|
||||
? 'Listening…'
|
||||
: 'Tell your journal…',
|
||||
hintStyle: voiceState.voiceModeActive
|
||||
? const TextStyle(fontStyle: FontStyle.italic)
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 10),
|
||||
),
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
textInputAction: TextInputAction.newline,
|
||||
enabled: !isStreaming && !voiceState.voiceModeActive,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
VoiceMicButton(
|
||||
mode: voiceState.mode,
|
||||
voiceModeActive: voiceState.voiceModeActive,
|
||||
amplitude: voiceState.amplitude,
|
||||
onTap: _toggleVoiceMode,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_GradientSendButton(
|
||||
onPressed: (isStreaming || voiceState.voiceModeActive)
|
||||
? null
|
||||
: _sendReply,
|
||||
isStreaming: isStreaming,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (isWide) {
|
||||
body = Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 700),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
return body;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _todayLabel() {
|
||||
final now = DateTime.now();
|
||||
const days = [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||||
'Friday', 'Saturday', 'Sunday'
|
||||
];
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a single journal message. For the daily-prep assistant message,
|
||||
/// also renders a WeatherCard above the bubble (weather lives in the
|
||||
/// nested metadata.sections.weather payload).
|
||||
class _JournalMessageItem extends StatelessWidget {
|
||||
final Message message;
|
||||
|
||||
const _JournalMessageItem({required this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = message.metadata;
|
||||
final isAssistant = message.role == MessageRole.assistant;
|
||||
final isPrep = isAssistant &&
|
||||
meta != null &&
|
||||
meta['kind'] == 'daily_prep';
|
||||
|
||||
Map<String, dynamic>? weatherData;
|
||||
if (isPrep) {
|
||||
// The journal prep stores its structured data under metadata.sections.
|
||||
final sections = meta['sections'] as Map<String, dynamic>?;
|
||||
final weather = sections?['weather'];
|
||||
if (weather is List && weather.isNotEmpty) {
|
||||
// Show the first location's weather card. The widget expects a
|
||||
// single location dict; we pass the first one through.
|
||||
weatherData = weather.first as Map<String, dynamic>?;
|
||||
} else if (weather is Map) {
|
||||
weatherData = weather as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (weatherData != null) WeatherCard(weather: weatherData),
|
||||
ChatMessageBubble(message: message),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientSendButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final bool isStreaming;
|
||||
|
||||
const _GradientSendButton({
|
||||
required this.onPressed,
|
||||
required this.isStreaming,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final disabled = onPressed == null;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: disabled
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(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,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}';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -7,10 +7,12 @@ import '../../core/exceptions.dart';
|
||||
import '../../core/wikilink_syntax.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/notes_provider.dart';
|
||||
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();
|
||||
@@ -19,15 +21,19 @@ class NoteEditScreen extends ConsumerStatefulWidget {
|
||||
class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
final _titleController = TextEditingController();
|
||||
final _contentController = TextEditingController();
|
||||
final _tagController = TextEditingController();
|
||||
List<String> _tags = [];
|
||||
int? _projectId;
|
||||
bool _preview = false;
|
||||
bool _saving = false;
|
||||
late String _noteType;
|
||||
|
||||
// Future is created once in initState so FutureBuilder never restarts it.
|
||||
late final Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_noteType = widget.noteType ?? 'note';
|
||||
_initFuture =
|
||||
widget.noteId != null ? _loadExisting() : Future.value();
|
||||
}
|
||||
@@ -36,6 +42,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_contentController.dispose();
|
||||
_tagController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -44,6 +51,25 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
await ref.read(notesRepositoryProvider).getOne(widget.noteId!);
|
||||
_titleController.text = note.title;
|
||||
_contentController.text = note.body;
|
||||
_tags = List<String>.from(note.tags);
|
||||
_projectId = note.projectId;
|
||||
_noteType = note.noteType;
|
||||
}
|
||||
|
||||
void _addTag(String raw) {
|
||||
final tag = raw.trim().replaceAll(',', '').toLowerCase();
|
||||
if (tag.isEmpty || _tags.contains(tag)) {
|
||||
_tagController.clear();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_tags = [..._tags, tag];
|
||||
_tagController.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _removeTag(String tag) {
|
||||
setState(() => _tags = _tags.where((t) => t != tag).toList());
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
@@ -81,12 +107,24 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
if (widget.noteId == null) {
|
||||
await ref.read(notesProvider.notifier).create(title, body);
|
||||
await ref.read(notesProvider.notifier).create(
|
||||
title,
|
||||
body,
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
noteType: _noteType,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
} else {
|
||||
await ref
|
||||
.read(notesProvider.notifier)
|
||||
.updateNote(widget.noteId!, title, body);
|
||||
await ref.read(notesProvider.notifier).updateNote(
|
||||
widget.noteId!,
|
||||
title,
|
||||
body,
|
||||
tags: _tags,
|
||||
projectId: _projectId,
|
||||
clearProject: _projectId == null,
|
||||
noteType: _noteType,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
} on AppException catch (e) {
|
||||
@@ -106,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(
|
||||
@@ -147,7 +200,23 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: _TagInput(
|
||||
tags: _tags,
|
||||
controller: _tagController,
|
||||
onAdd: _addTag,
|
||||
onRemove: _removeTag,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: ProjectSelector(
|
||||
value: _projectId,
|
||||
onChanged: (id) => setState(() => _projectId = id),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: _preview
|
||||
? Markdown(
|
||||
@@ -177,3 +246,57 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagInput extends StatelessWidget {
|
||||
final List<String> tags;
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onAdd;
|
||||
final ValueChanged<String> onRemove;
|
||||
|
||||
const _TagInput({
|
||||
required this.tags,
|
||||
required this.controller,
|
||||
required this.onAdd,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
...tags.map(
|
||||
(tag) => Chip(
|
||||
label: Text('#$tag', style: const TextStyle(fontSize: 12)),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
deleteIcon: const Icon(Icons.close, size: 14),
|
||||
onDeleted: () => onRemove(tag),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add tag…',
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 4),
|
||||
),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: onAdd,
|
||||
onChanged: (v) {
|
||||
if (v.endsWith(',') || v.endsWith(' ')) {
|
||||
onAdd(v.replaceAll(RegExp(r'[, ]+$'), ''));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,19 +36,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
|
||||
|
||||
try {
|
||||
final dio = Dio(BaseOptions(connectTimeout: const Duration(seconds: 5)));
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
// Accept any HTTP status — only network-level failures throw.
|
||||
// A 401 or 200 both mean the server is reachable.
|
||||
validateStatus: (_) => true,
|
||||
));
|
||||
await dio.get('$url/api/auth/status');
|
||||
// 401 is fine — server is reachable
|
||||
} on DioException catch (_) {
|
||||
} on DioException catch (e) {
|
||||
final msg = switch (e.type) {
|
||||
DioExceptionType.badCertificate =>
|
||||
'SSL certificate error. If using a self-signed cert, it must be trusted on this device.',
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
'Connection timed out. The server may be down or unreachable.',
|
||||
DioExceptionType.connectionError =>
|
||||
'Cannot connect: ${e.message ?? "network error"}',
|
||||
_ => 'Error: ${e.message ?? e.type.name}',
|
||||
};
|
||||
setState(() {
|
||||
_testing = false;
|
||||
_error = 'Could not reach server. Check the URL and try again.';
|
||||
_error = msg;
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_testing = false;
|
||||
_error = 'Could not reach server. Check the URL and try again.';
|
||||
_error = 'Unexpected error: $e';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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.journal);
|
||||
} else if (status == AuthStatus.offline && hasEverLoggedIn) {
|
||||
// Server unreachable but this user has logged in before — land them on
|
||||
// the briefing with the offline banner rather than the login screen.
|
||||
context.go(Routes.journal);
|
||||
} else {
|
||||
context.go(Routes.login);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ 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/task.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../widgets/project_selector.dart';
|
||||
|
||||
class TaskEditScreen extends ConsumerStatefulWidget {
|
||||
final int? taskId;
|
||||
const TaskEditScreen({super.key, this.taskId});
|
||||
final int? initialProjectId;
|
||||
const TaskEditScreen({super.key, this.taskId, this.initialProjectId});
|
||||
|
||||
@override
|
||||
ConsumerState<TaskEditScreen> createState() => _TaskEditScreenState();
|
||||
@@ -22,14 +25,16 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
TaskStatus _status = TaskStatus.todo;
|
||||
TaskPriority _priority = TaskPriority.medium;
|
||||
DateTime? _dueDate;
|
||||
int? _projectId;
|
||||
bool _saving = false;
|
||||
List<Task> _subTasks = [];
|
||||
|
||||
// Future is created once in initState so FutureBuilder never restarts it.
|
||||
late final Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_projectId = widget.initialProjectId;
|
||||
_initFuture =
|
||||
widget.taskId != null ? _loadExisting() : Future.value();
|
||||
}
|
||||
@@ -42,13 +47,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadExisting() async {
|
||||
final task =
|
||||
await ref.read(tasksRepositoryProvider).getOne(widget.taskId!);
|
||||
final repo = ref.read(tasksRepositoryProvider);
|
||||
final task = await repo.getOne(widget.taskId!);
|
||||
_titleController.text = task.title;
|
||||
_descController.text = task.description ?? '';
|
||||
_status = task.status;
|
||||
_priority = task.priority;
|
||||
_dueDate = task.dueDate;
|
||||
_projectId = task.projectId;
|
||||
// Fetch sub-tasks
|
||||
final api = ref.read(tasksApiProvider);
|
||||
final subs = await api.getSubTasks(widget.taskId!);
|
||||
setState(() => _subTasks = subs);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
@@ -64,16 +74,18 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
status: _status,
|
||||
priority: _priority,
|
||||
dueDate: _dueDate,
|
||||
projectId: _projectId,
|
||||
);
|
||||
} else {
|
||||
await ref.read(tasksProvider.notifier).updateTask(widget.taskId!, {
|
||||
'title': _titleController.text.trim(),
|
||||
'description': _descController.text.trim().isEmpty
|
||||
'body': _descController.text.trim().isEmpty
|
||||
? null
|
||||
: _descController.text.trim(),
|
||||
'status': _status.value,
|
||||
'priority': _priority.value,
|
||||
'due_date': _dueDate?.toIso8601String(),
|
||||
'project_id': _projectId,
|
||||
});
|
||||
}
|
||||
if (mounted) context.pop();
|
||||
@@ -119,6 +131,54 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
if (date != null) setState(() => _dueDate = date);
|
||||
}
|
||||
|
||||
Future<void> _addSubTask() async {
|
||||
final titleCtrl = TextEditingController();
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Add sub-task'),
|
||||
content: TextField(
|
||||
controller: titleCtrl,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(ctx, titleCtrl.text.trim()),
|
||||
child: const Text('Add')),
|
||||
],
|
||||
),
|
||||
);
|
||||
titleCtrl.dispose();
|
||||
if (result == null || result.isEmpty || !mounted) return;
|
||||
try {
|
||||
final api = ref.read(tasksApiProvider);
|
||||
final sub = await api.create(
|
||||
title: result,
|
||||
status: TaskStatus.todo,
|
||||
priority: TaskPriority.none,
|
||||
projectId: _projectId,
|
||||
parentId: widget.taskId,
|
||||
);
|
||||
ref.invalidate(tasksProvider);
|
||||
setState(() => _subTasks = [..._subTasks, sub]);
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
@@ -155,70 +215,90 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Required' : null,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty)
|
||||
? 'Required'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _descController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TaskStatus>(
|
||||
// ignore: deprecated_member_use
|
||||
value: _status,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Status',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: TaskStatus.values
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s, child: Text(s.label)))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _status = v!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TaskPriority>(
|
||||
// ignore: deprecated_member_use
|
||||
value: _priority,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: TaskPriority.values
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p, child: Text(p.label)))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _priority = v!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(_dueDate == null
|
||||
? 'No due date'
|
||||
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
||||
leading: const Icon(Icons.calendar_today),
|
||||
trailing: _dueDate != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () =>
|
||||
setState(() => _dueDate = null),
|
||||
)
|
||||
: null,
|
||||
onTap: _pickDate,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ProjectSelector(
|
||||
value: _projectId,
|
||||
onChanged: (id) =>
|
||||
setState(() => _projectId = id),
|
||||
),
|
||||
// Sub-tasks (only when editing an existing task)
|
||||
if (widget.taskId != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_SubTasksSection(
|
||||
subTasks: _subTasks,
|
||||
onAdd: _addSubTask,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _descController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TaskStatus>(
|
||||
initialValue: _status,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Status',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: TaskStatus.values
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s, child: Text(s.label)))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _status = v!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TaskPriority>(
|
||||
initialValue: _priority,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: TaskPriority.values
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p, child: Text(p.label)))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _priority = v!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(_dueDate == null
|
||||
? 'No due date'
|
||||
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
|
||||
leading: const Icon(Icons.calendar_today),
|
||||
trailing: _dueDate != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () =>
|
||||
setState(() => _dueDate = null),
|
||||
)
|
||||
: null,
|
||||
onTap: _pickDate,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -226,3 +306,91 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-tasks section ─────────────────────────────────────────────────────────
|
||||
|
||||
class _SubTasksSection extends ConsumerWidget {
|
||||
final List<Task> subTasks;
|
||||
final VoidCallback onAdd;
|
||||
const _SubTasksSection({required this.subTasks, required this.onAdd});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Sub-tasks',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Add'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
textStyle: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (subTasks.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'No sub-tasks yet.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...subTasks.map((sub) => _SubTaskTile(task: sub, ref: ref)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubTaskTile extends StatelessWidget {
|
||||
final Task task;
|
||||
final WidgetRef ref;
|
||||
const _SubTaskTile({required this.task, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDone = task.status == TaskStatus.done;
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Checkbox(
|
||||
value: isDone,
|
||||
onChanged: (_) async {
|
||||
final newStatus =
|
||||
isDone ? TaskStatus.todo : TaskStatus.done;
|
||||
await ref.read(tasksProvider.notifier).updateTask(
|
||||
task.id, {'status': newStatus.value});
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
task.title,
|
||||
style: TextStyle(
|
||||
decoration: isDone ? TextDecoration.lineThrough : null,
|
||||
color: isDone
|
||||
? Theme.of(context).colorScheme.outline
|
||||
: null,
|
||||
),
|
||||
),
|
||||
onTap: () => context.push(
|
||||
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
|
||||
import '../data/models/message.dart';
|
||||
|
||||
class JournalPrepCard extends StatefulWidget {
|
||||
/// The first assistant message from today's journal — the daily prep
|
||||
/// (LLM-generated briefing-style opener), or null if not yet generated.
|
||||
final Message? message;
|
||||
|
||||
/// Called when the user taps "Generate now".
|
||||
final VoidCallback? onGenerateNow;
|
||||
|
||||
const JournalPrepCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onGenerateNow,
|
||||
});
|
||||
|
||||
@override
|
||||
State<JournalPrepCard> createState() => _JournalPrepCardState();
|
||||
}
|
||||
|
||||
class _JournalPrepCardState extends State<JournalPrepCard> {
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.menu_book_outlined, size: 18, color: scheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_todayLabel(),
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (widget.message == null) ...[
|
||||
Text(
|
||||
'No prep 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,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,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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/projects_provider.dart';
|
||||
|
||||
/// Dropdown for picking a project. Pass [value] as the current project id
|
||||
/// (null = no project) and [onChanged] to receive updates.
|
||||
class ProjectSelector extends ConsumerWidget {
|
||||
final int? value;
|
||||
final ValueChanged<int?> onChanged;
|
||||
final InputDecoration? decoration;
|
||||
|
||||
const ProjectSelector({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.decoration,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final projectsAsync = ref.watch(projectsProvider);
|
||||
|
||||
return projectsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
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(
|
||||
labelText: 'Project (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('No project'),
|
||||
),
|
||||
...active.map(
|
||||
(p) => DropdownMenuItem<int?>(
|
||||
value: p.id,
|
||||
child: Text(p.title, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: onChanged,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
+506
-26
@@ -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
|
||||
@@ -238,32 +302,48 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_markdown:
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown
|
||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
||||
name: flutter_markdown_plus
|
||||
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7+1"
|
||||
version: "1.0.7"
|
||||
flutter_riverpod:
|
||||
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:
|
||||
|
||||
+13
-5
@@ -2,7 +2,7 @@ name: fabled_app
|
||||
description: "FabledAssistant mobile client for Android."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
version: 0.0.0+0 # overridden at build time via --build-name / --build-number
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
@@ -12,18 +12,26 @@ dependencies:
|
||||
sdk: flutter
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_riverpod: ^2.5.1
|
||||
go_router: ^14.2.0
|
||||
flutter_riverpod: ^3.3.1
|
||||
go_router: ^17.1.0
|
||||
dio: ^5.6.0
|
||||
cookie_jar: ^4.0.8
|
||||
dio_cookie_manager: ^3.1.1
|
||||
path_provider: ^2.1.4
|
||||
shared_preferences: ^2.3.2
|
||||
flutter_markdown: ^0.7.3
|
||||
markdown: ^7.2.2
|
||||
package_info_plus: ^8.0.0
|
||||
package_info_plus: ^9.0.0
|
||||
open_file: ^3.3.2
|
||||
permission_handler: ^11.3.1
|
||||
flutter_inappwebview: ^6.1.5
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
google_fonts: ^8.0.2
|
||||
flutter_timezone: ^5.0.2
|
||||
url_launcher: ^6.3.1
|
||||
record: ^6.2.0
|
||||
vad: ^0.0.7
|
||||
just_audio: ^0.9.39
|
||||
table_calendar: ^3.1.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user