Compare commits

..

38 Commits

Author SHA1 Message Date
bvandeusen cab8d7104f Merge pull request 'changes to PR process and build number implementation' (#5) from dev into main
Reviewed-on: #5
2026-03-12 07:55:52 -04:00
bvandeusen baba5c3462 feat: inject version from release tag at build time
pubspec.yaml now holds a placeholder (0.0.0+0) that is never updated.
CI strips the v* tag to derive --build-name (e.g. 26.03.12) and
--build-number (e.g. 260312) and passes them to flutter build apk.
2026-03-12 07:52:26 -04:00
bvandeusen 97c049e453 fix: guard release signingConfig behind key.properties existence check
signingConfigs.create() was running unconditionally, casting null
properties to String before the buildTypes guard could take effect.
CI has no key.properties so the cast threw. Now falls back to debug
signing when the keystore file is absent.
2026-03-12 07:51:11 -04:00
bvandeusen a19af3388d Merge pull request 'bug fix' (#4) from dev into main
Reviewed-on: #4
2026-03-12 06:48:30 -04:00
bvandeusen bae6597ec2 fix: remove double status-bar gap between capture bar and AppBar
The narrow-layout body Column had no SafeArea, so the child screen's
AppBar was adding the full status bar height on top of the height already
consumed by _QuickCaptureBar's own SafeArea. MediaQuery.removePadding
(removeTop: true) on the Expanded child tells the inner AppBar the top
inset is already handled.
2026-03-12 06:28:34 -04:00
bvandeusen bc48a49de8 feat: upgrade Riverpod 3, go_router 17, google_fonts 8, package_info_plus 9
Patch/minor (via flutter pub upgrade):
- dio 5.9.1 → 5.9.2
- dio_cookie_manager 3.3.0 → 3.4.0
- dio_web_adapter 2.1.1 → 2.1.2
- hooks 1.0.1 → 1.0.2
- native_toolchain_c 0.17.4 → 0.17.5

Major (via flutter pub upgrade --major-versions + code migration):
- flutter_riverpod 2.6.1 → 3.3.1 + riverpod 2.6.1 → 3.2.1
  - StateNotifierProvider + StateNotifier → NotifierProvider + Notifier
  - StateProvider → NotifierProvider with simple Notifier
  - FamilyAsyncNotifier build(arg) → build() with this.arg
  - AsyncValue.valueOrNull → .value (now T?)
- go_router 14.8.1 → 17.1.0
- google_fonts 6.3.3 → 8.0.2
- package_info_plus 8.3.1 → 9.0.0
2026-03-12 06:21:44 -04:00
bvandeusen a687e3637f feat: upgrade Riverpod 3, go_router 17, google_fonts 8, package_info_plus 9
Migrate StateNotifierProvider/StateNotifier → NotifierProvider/Notifier,
StateProvider → NotifierProvider with simple notifier, FamilyAsyncNotifier
removed in Riverpod 3 (replaced with constructor-arg pattern), and rename
.valueOrNull → .value throughout all providers and screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 06:21:17 -04:00
bvandeusen c8becd6afd fix: revert null-aware map entries; disable use_null_aware_elements lint
The ? operator on map entries applies to the key, not the value.
String literal keys can never be null, so ?'key': value was flagged as
invalid_null_aware_operator. Reverted to if (x != null) 'key': x form
and disabled the lint project-wide since it doesn't apply here.
2026-03-12 00:44:04 -04:00
bvandeusen e63370bf0a Merge pull request 'v26.03.12.1' (#3) from dev into main
Reviewed-on: #3
2026-03-12 00:41:40 -04:00
bvandeusen f39b8ddb30 chore: bump version to 26.03.12+1 2026-03-12 00:40:10 -04:00
bvandeusen def7519feb fix: resolve all flutter analyze warnings and infos
- app.dart: add braces to single-statement if body
- briefing_api.dart: escape <id> in doc comment (unintended HTML)
- notes_api.dart, tasks_api.dart, projects_api.dart: use null-aware
  map elements (?'key': value) instead of if-null guards
- task_edit_screen.dart: remove unused tasks_api.dart import
- task_edit_screen.dart, project_selector.dart: suppress
  deprecated_member_use on DropdownButtonFormField.value (value is
  the controlled-widget param; switching to initialValue would break
  current-selection display)
- project_selector.dart: use _ instead of __ for ignored error params
2026-03-12 00:36:05 -04:00
bvandeusen e86d1a59af fix: redirect to /briefing after auth (was /notes which no longer exists) 2026-03-12 00:32:38 -04:00
bvandeusen c8f3861eb5 ci: only trigger on v* release tags, never on branch pushes 2026-03-12 00:24:59 -04:00
bvandeusen a2e734c498 Merge pull request 'ci: replace JS actions with shell steps — Flutter container has no Node' (#2) from dev into main
Reviewed-on: #2
2026-03-12 00:23:06 -04:00
bvandeusen 3a336ddf88 ci: replace JS actions with shell steps — Flutter container has no Node
actions/checkout and actions/upload-artifact are JavaScript actions.
When container: is set, act runs them inside the container (not on the
host runner), but ghcr.io/cirruslabs/flutter:stable has no Node.js.

Replace both with equivalent shell commands:
- checkout: git clone + git checkout SHA
- artifact upload: curl to Forgejo API (best-effort, non-blocking)
The release APK attach step was already a pure shell step and is unchanged.
2026-03-12 00:21:47 -04:00
bvandeusen 3a8cc0db23 Merge pull request 'v26.03.11.1' (#1) from dev into main
Reviewed-on: #1
2026-03-11 23:47:45 -04:00
bvandeusen fa1b65484c ci: attach APK to existing release when created via Forgejo UI 2026-03-11 23:29:24 -04:00
bvandeusen 4b6fca39a8 ci: merge build into ci workflow with needs gate; update README 2026-03-11 23:24:48 -04:00
bvandeusen 8a31034621 ci: switch runner label to py3.12-node22 to match act runner config 2026-03-11 23:19:36 -04:00
bvandeusen 6232c7c99a feat: app overhaul — Briefing-first navigation, Library, capture queue
Plans 1-3 implemented:

Plan 1 — Foundation
- Add google_fonts ^6.2.1
- lib/core/theme.dart: slate-indigo ColorScheme (dark/light), Fraunces
  headings, GradientButton widget
- lib/providers/capture_work_queue_provider.dart: in-memory sequential
  work queue; CaptureWorkQueueNotifier drains one item at a time;
  captureResultProvider feeds snackbars to UI
- lib/app.dart: wire fabledDarkTheme/fabledLightTheme; replace blocking
  _QuickCaptureBar with queue-based implementation (progress bar, badge)

Plan 2 — Navigation
- 3-tab shell: Briefing · Library · Chat (was Notes · Tasks · Projects · Chat)
- lib/screens/library/library_screen.dart: unified notes+tasks+projects list
  with filter pills, status sub-filter for tasks, live search, FAB
- lib/widgets/library_item_card.dart: NoteLibraryCard, TaskLibraryCard
  (status cycle), ProjectLibraryCard
- lib/screens/chat/conversations_tab_screen.dart: focused replacement for
  ConversationsListScreen
- Delete 5 dead screens: notes_list, tasks_list, project_list,
  conversations_list, quick_capture

Plan 3 — Briefing
- lib/data/models/briefing_conversation.dart
- lib/data/api/briefing_api.dart: getToday, getHistory, getMessages,
  triggerSlot
- lib/providers/briefing_provider.dart: BriefingNotifier with sendReply
  (optimistic + SSE + poll, same pattern as MessagesNotifier) and refresh
- lib/widgets/chat_message_bubble.dart: extracted + redesigned shared bubble
  (ghost user bubbles, left-accent assistant bubbles)
- lib/widgets/briefing_digest_card.dart: expandable first-message card
- lib/screens/briefing/briefing_screen.dart: digest card, conversation list,
  streaming reply bar, refresh button, history overflow menu
- lib/screens/briefing/briefing_history_screen.dart: read-only past dates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:17:38 -04:00
bvandeusen 3422caebfc docs: add Plan 3 — BriefingScreen implementation plan 2026-03-11 22:52:19 -04:00
bvandeusen 3bd9c64477 docs: fabled app overhaul design spec 2026-03-11 22:18:59 -04:00
bvandeusen ef4872e24a Bump actions/checkout v4 → v6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 21:46:53 -04:00
bvandeusen 0999774f34 Add dev branch support to build workflow
dev push  → artifact named fabledapp-dev-<sha>
main push → artifact named fabledapp-<sha>
tag push  → artifact + Forgejo Release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 21:17:53 -04:00
bvandeusen 46425a4b27 Add Forgejo Release creation on version tag push
Tag pushes (v*) now build the APK and create a Forgejo Release with
the APK attached as a downloadable asset, in addition to uploading the
workflow artifact. Requires RELEASE_TOKEN secret (write:repository scope).
To release: git tag v26.03.09 && git push origin v26.03.09

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 21:04:50 -04:00
bvandeusen 86244cdfbc Add path filters to CI and build workflows
CI skips on changes to platform dirs (ios, linux, macos, web, windows),
docs, and README. Build skips on test-only changes since tests don't
affect the release APK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 20:39:12 -04:00
bvandeusen 6abc4257be Add Forgejo Actions CI and APK build workflows
ci.yml: flutter analyze + flutter test on every push, runs in
  ghcr.io/cirruslabs/flutter:stable container (includes Flutter, Dart,
  Java, Android SDK)
build.yml: release APK build on main push, uploaded as workflow artifact
  (downloadable from Forgejo Actions UI, retained 30 days)

No secrets required. Uses existing swarm-runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 20:29:20 -04:00
bvandeusen 04b7e1cc8a Fix update dialog looping after download completes
After OpenFile.open() the state was reset to UpdateStatus.available,
which re-triggered the app.dart listener (downloading → available
satisfies the prev != available condition) and showed the dialog again.
Fix: reset to idle so the system installer can proceed uninterrupted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 22:14:15 -05:00
bvandeusen 3c3055d536 Improve project-task integration and sub-task support
- TasksApi: add getSubTasks(parentId) + parentId param to create()
- TasksRepository: expose new API methods
- app.dart: invalidate projectsProvider + projectMilestonesProvider
  after quick capture creates a task; taskNew route reads ?projectId=
  query param and passes as initialProjectId to TaskEditScreen
- project_list_screen.dart: _AddTaskRow passes projectId query param
  so the task editor pre-fills the project on navigation
- task_edit_screen.dart: fully rewritten with initialProjectId support,
  sub-task fetching/creation, and _SubTasksSection widget

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:59:58 -05:00
bvandeusen 868cb0e49e Add milestone data layer and redesign Projects tab
Milestone data layer:
- lib/data/models/milestone.dart: Milestone model with progress fields
  (total, completed, pct) from backend progress endpoint
- lib/data/api/milestones_api.dart: CRUD client for
  /api/projects/:id/milestones
- lib/data/repositories/milestones_repository.dart: thin repository wrapper
- lib/providers/milestones_provider.dart: FutureProvider.family keyed by
  project ID — lazily fetched per project when expanded
- api_client_provider.dart: registers milestonesApiProvider and
  milestonesRepositoryProvider

Projects tab redesign (project_list_screen.dart):
- Each project is now an ExpansionTile showing open task count in subtitle
- Expanding a project fetches its milestones and groups tasks under milestone
  headers with inline progress bar (completed/total count)
- Tasks show status icon, due date with overdue highlighting, priority dot
- 'No milestone' section at bottom for unassigned tasks
- Tapping a task navigates to the task editor
- Long-press on project title opens archive/complete/delete bottom sheet
- 'New task' button at bottom of each expanded project section

Bump version to 26.03.02+1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:33:45 -05:00
bvandeusen fceae5529d Add Projects feature; sync Note/Task models with backend additions
## New: Projects
- Project model, API, repository, Riverpod provider
- ProjectListScreen: active/archived sections, create dialog, long-press status/delete
- ProjectSelector widget: DropdownButtonFormField for note + task editors
- Projects tab added to shell (bottom nav + navigation rail, 4th position)
- /projects route registered in GoRouter ShellRoute

## Updated: Note model
- Added tags: List<String>, projectId: int?, milestoneId: int?
- NotesApi.create/update pass tags and project_id
- NotesRepository and NotesNotifier signatures updated
- NoteEditScreen: chip-based tag input + ProjectSelector

## Updated: Task model
- Added projectId: int?, milestoneId: int?, parentId: int?
- TasksApi.create passes project_id; update payload includes project_id
- TasksRepository and TasksNotifier signatures updated
- TaskEditScreen: ProjectSelector added; project_id sent on save

## Provider fix
- ProjectsNotifier.update renamed to updateProject to avoid conflict
  with AsyncNotifier.update(FutureOr<State> Function(State)) base method

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:52:59 -05:00
bvandeusen f30aa8d273 Add release signing config and fix missing INTERNET permission
- Configure release signing via key.properties + Gradle Kotlin DSL
- Add INTERNET and ACCESS_NETWORK_STATE permissions to AndroidManifest
  (Flutter injects INTERNET automatically in debug builds but not release,
  causing DNS failures in sideloaded APKs)
- Restore android/.gitignore entries lost during signing setup
- Gitignore key.properties and fabled-release-key.jks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 18:16:44 -05:00
bvandeusen abf91874c3 Show specific error reason on setup screen connection failure
Instead of a generic "Could not reach server" for all failures, show
the actual cause: SSL cert error, timeout, or the raw connection error
message. This makes it possible to diagnose setup issues on-device
without needing a debugger.

Also extend connect timeout from 5s to 10s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:48:49 -05:00
bvandeusen 54c2588bd6 Name release APK Fabled-<version>.<build>.apk
Gradle applicationVariants hook renames the output from app-release.apk
to e.g. Fabled-26.03.01.1.apk. The correctly named file is produced at
build/app/outputs/apk/release/ and should be used for Forgejo releases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:42:24 -05:00
bvandeusen ccaec61de2 Fix setup screen always rejecting valid servers
Dio throws DioException for 4xx responses by default, so a server
returning 401 on /api/auth/status (correct behaviour for an
unauthenticated request) was caught and shown as "Could not reach
server", preventing the URL from ever being saved.

Set validateStatus: (_) => true so any HTTP response (200, 401, 404…)
counts as "reachable". Only actual network failures (no response) now
trigger the error message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:38:01 -05:00
bvandeusen 467fa6a553 Migrate flutter_markdown → flutter_markdown_plus; fix app label
flutter_markdown was discontinued; flutter_markdown_plus is the
maintained drop-in replacement (same API, updated imports only).

Also set android:label to "Fabled" so the app appears correctly
on the home screen and in the app drawer instead of "fabled_app".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:26:01 -05:00
bvandeusen 140f6cf63a Set version to 26.03.01.1 (YY.MM.DD.iteration format)
pubspec: 26.03.01+1 (Flutter uses + to separate versionName/versionCode)
Update checker combines version + buildNumber → "26.03.01.1" for
comparison against Forgejo release tags in the same format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:17:23 -05:00
bvandeusen 4c2b2a0d1a Set default update repository URL to fabledsword Forgejo instance
Pre-configure https://git.fabledsword.com/bvandeusen/FabledApp so update
checks work out of the box on fresh installs. The URL can still be
overridden in Settings if needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:12:22 -05:00
60 changed files with 6718 additions and 1173 deletions
+120
View File
@@ -0,0 +1,120 @@
# CI runs only on release tags.
#
# Tag v*: analyze + test → build APK → attach to Forgejo Release
#
# 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
name: CI & Build
on:
push:
tags: ["v*"]
jobs:
analyze:
name: Analyze & test
runs-on: py3.12-node22
container:
image: ghcr.io/cirruslabs/flutter:stable
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]
runs-on: py3.12-node22
container:
image: ghcr.io/cirruslabs/flutter:stable
steps:
- name: Checkout
run: |
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
git checkout "$GITHUB_SHA"
- name: Install dependencies
run: flutter pub get
- 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: Set artifact name
id: artifact
run: |
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Upload artifact to Forgejo
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
APK: build/app/outputs/flutter-apk/app-release.apk
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
run: |
# Upload the APK as a workflow artifact via the Forgejo API.
curl -s -X POST "$API/actions/artifacts" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "name=$ARTIFACT_NAME" \
-F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)."
- name: Publish Forgejo release
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
run: |
# Look for an existing release (created via the UI or a prior run).
EXISTING=$(curl -s \
"$API/releases/tags/$TAG" \
-H "Authorization: token $RELEASE_TOKEN")
RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -n "$RELEASE_ID" ]; then
echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..."
else
echo "No existing release found, creating $TAG..."
RESPONSE=$(curl -s -X POST "$API/releases" \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}")
RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release. API response:"
echo "$RESPONSE"
exit 1
fi
echo "Release created with id $RELEASE_ID."
fi
curl -s -X POST "$API/releases/$RELEASE_ID/assets" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk"
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
+55 -20
View File
@@ -1,20 +1,21 @@
# 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.
- **Library** — unified browsable list of notes, tasks, and projects with filter pills (All · Notes · Tasks · Projects). Tasks have a secondary status sub-filter and a tappable status icon to cycle todo → in progress → done without opening the editor. Inline search filters results live.
- **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one.
- **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-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 +39,33 @@ 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 redirect guards + 3-tab shell
├── 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)
│ ├── models/ # Plain Dart models with fromJson/toJson
│ └── repositories/ # Thin wrappers over API classes
── providers/ # Riverpod providers (state + dependency wiring)
│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
│ └── capture_work_queue_provider.dart # Sequential in-memory capture queue
├── screens/
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
│ ├── library/ # LibraryScreen (unified notes/tasks/projects)
│ ├── chat/ # ConversationsTabScreen + ChatScreen
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
│ ├── tasks/ # TaskEditScreen
│ └── settings/ auth/ setup/ splash/
└── widgets/
├── chat_message_bubble.dart # Shared bubble (used by Chat + Briefing)
├── briefing_digest_card.dart # Expandable first-message card
└── library_item_card.dart # Note / task / project row widgets
```
**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`
## Building a Release APK
@@ -59,4 +73,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.03.11 && git push origin v26.03.11
```
Requires a `RELEASE_TOKEN` secret (Forgejo PAT with `write:repository` scope) set in repo Settings → Secrets → Actions.
+3
View File
@@ -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
+2 -4
View File
@@ -7,8 +7,6 @@ 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
+33 -6
View File
@@ -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.versionName}.${variant.versionCode}.apk"
}
}
}
+3 -1
View File
@@ -1,8 +1,10 @@
<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.REQUEST_INSTALL_PACKAGES" />
<application
android:label="fabled_app"
android:label="Fabled"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
@@ -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 498523 (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
@@ -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
+131 -148
View File
@@ -4,24 +4,26 @@ import 'package:go_router/go_router.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 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart';
import 'providers/notes_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/update_provider.dart';
import 'providers/tasks_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/briefing/briefing_screen.dart';
import 'screens/chat/chat_screen.dart';
import 'screens/chat/conversations_list_screen.dart';
import 'screens/chat/conversations_tab_screen.dart';
import 'screens/library/library_screen.dart';
import 'screens/notes/note_detail_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';
// ChangeNotifier that fires when auth or server URL changes,
// used as GoRouter.refreshListenable so the router re-evaluates redirects
@@ -93,7 +95,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,
@@ -111,16 +117,16 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.notes,
builder: (_, _) => const NotesListScreen(),
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
),
GoRoute(
path: Routes.tasks,
builder: (_, _) => const TasksListScreen(),
path: Routes.library,
builder: (_, _) => const LibraryScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsListScreen(),
builder: (_, _) => const ConversationsTabScreen(),
),
],
),
@@ -137,7 +143,11 @@ class _Shell extends ConsumerStatefulWidget {
}
class _ShellState extends ConsumerState<_Shell> {
static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations];
static const _tabs = [
Routes.briefing,
Routes.library,
Routes.conversations,
];
@override
void initState() {
@@ -225,8 +235,6 @@ class _ShellState extends ConsumerState<_Shell> {
final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location);
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) {
@@ -244,14 +252,14 @@ class _ShellState extends ConsumerState<_Shell> {
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.note_outlined),
selectedIcon: Icon(Icons.note),
label: Text('Notes'),
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
NavigationRailDestination(
icon: Icon(Icons.check_box_outlined),
selectedIcon: Icon(Icons.check_box),
label: Text('Tasks'),
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: Text('Library'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
@@ -275,17 +283,34 @@ class _ShellState extends ConsumerState<_Shell> {
body: Column(
children: [
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]),
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.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
],
),
);
@@ -301,13 +326,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,61 +339,15 @@ 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;
@@ -389,24 +366,18 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
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);
if (mounted) {
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
}
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
String _hintForLocation(String location) {
if (location.startsWith(Routes.tasks)) return 'Add a task…';
if (location.startsWith(Routes.library) &&
location.contains('tasks')) { return 'Add a task…'; }
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
@@ -414,61 +385,82 @@ 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: _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,
),
),
),
),
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 +477,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,
);
}
+3
View File
@@ -9,8 +9,11 @@ abstract class Routes {
static const tasks = '/tasks';
static const taskNew = '/tasks/new';
static const taskEdit = '/tasks/:id/edit';
static const projects = '/projects';
static const conversations = '/chat';
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const library = '/library';
}
+246
View File
@@ -0,0 +1,246 @@
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: CardThemeData(
color: _darkSurface,
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: _darkSurface,
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: _darkSurface,
indicatorColor: _darkPrimary.withValues(alpha: 0.2),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: _darkSurfaceVar,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _darkOutline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _darkOutline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _darkPrimary, width: 2),
),
),
dividerTheme: DividerThemeData(color: _darkOutline, thickness: 1),
chipTheme: ChipThemeData(
backgroundColor: _darkSurfaceVar,
labelStyle: TextStyle(color: _darkOnSurfaceVar, fontSize: 12),
side: BorderSide(color: _darkOutline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
ThemeData fabledLightTheme() {
final cs = ColorScheme(
brightness: Brightness.light,
primary: _lightPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(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: CardThemeData(
color: _lightSurface,
elevation: 1,
shadowColor: Colors.black.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: _lightSurface,
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: _lightSurface,
indicatorColor: _lightPrimary.withValues(alpha: 0.12),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: _lightSurfaceVar,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _lightOutline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _lightOutline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(color: _lightPrimary, width: 2),
),
),
dividerTheme: DividerThemeData(color: _lightOutline, thickness: 1),
chipTheme: ChipThemeData(
backgroundColor: _lightSurfaceVar,
labelStyle: TextStyle(color: _lightOnSurfaceVar, fontSize: 12),
side: BorderSide(color: _lightOutline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
// ── GradientButton ─────────────────────────────────────────────────────────────
// Use wherever the web app uses the indigo gradient button (send, primary actions).
class GradientButton extends StatelessWidget {
final VoidCallback? onPressed;
final Widget child;
final EdgeInsetsGeometry padding;
const GradientButton({
super.key,
required this.onPressed,
required this.child,
this.padding = const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
});
@override
Widget build(BuildContext context) {
final disabled = onPressed == null;
return AnimatedOpacity(
opacity: disabled ? 0.45 : 1.0,
duration: const Duration(milliseconds: 150),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: disabled
? null
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(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),
),
),
),
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:dio/dio.dart';
import '../models/briefing_conversation.dart';
import '../models/message.dart';
import 'api_client.dart';
class BriefingApi {
final Dio _dio;
const BriefingApi(this._dio);
/// GET /api/briefing/conversations/today
/// Returns (or creates) today's briefing conversation with messages embedded.
Future<BriefingConversation> getToday() async {
try {
final response = await _dio.get('/api/briefing/conversations/today');
return BriefingConversation.fromJson(
response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/conversations
/// Returns list of past briefing conversations (no messages embedded).
Future<List<BriefingConversation>> getHistory() async {
try {
final response = await _dio.get('/api/briefing/conversations');
final data = response.data as Map<String, dynamic>;
final list = data['conversations'] as List<dynamic>;
return list
.map((e) => BriefingConversation.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/conversations/`<id>`/messages
Future<List<Message>> getMessages(int convId) async {
try {
final response =
await _dio.get('/api/briefing/conversations/$convId/messages');
final data = response.data as Map<String, dynamic>;
final list = data['messages'] as List<dynamic>;
return list
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST /api/briefing/trigger body: {"slot": slot}
/// slot: "compilation" | "morning" | "midday" | "afternoon"
Future<void> triggerSlot(String slot) async {
try {
await _dio.post('/api/briefing/trigger', data: {'slot': slot});
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+71
View File
@@ -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);
}
}
}
+18 -2
View File
@@ -27,11 +27,18 @@ class NotesApi {
}
}
Future<Note> create(String title, String body) async {
Future<Note> create(
String title,
String body, {
List<String> tags = const [],
int? projectId,
}) async {
try {
final response = await _dio.post('/api/notes', data: {
'title': title,
'body': body,
'tags': tags,
if (projectId != null) 'project_id': projectId,
});
return Note.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
@@ -39,11 +46,20 @@ 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,
}) async {
try {
final response = await _dio.put('/api/notes/$id', data: {
'title': title,
'body': body,
'tags': tags,
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) {
+71
View File
@@ -0,0 +1,71 @@
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}) async {
try {
final response = await _dio.get(
'/api/projects',
queryParameters: status != null ? {'status': status} : null,
);
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,
}) async {
try {
final response = await _dio.post('/api/projects', data: {
'title': title,
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);
}
}
}
+18
View File
@@ -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,20 @@ class TasksApi {
}
}
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,35 @@
import 'message.dart';
class BriefingConversation {
final int id;
final String title;
final String? briefingDate; // YYYY-MM-DD or null
final List<Message> messages;
const BriefingConversation({
required this.id,
required this.title,
this.briefingDate,
required this.messages,
});
factory BriefingConversation.fromJson(Map<String, dynamic> json) {
final rawMessages = json['messages'] as List<dynamic>? ?? [];
return BriefingConversation(
id: json['id'] as int,
title: json['title'] as String? ?? '',
briefingDate: json['briefing_date'] as String?,
messages: rawMessages
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
BriefingConversation copyWith({List<Message>? messages}) =>
BriefingConversation(
id: id,
title: title,
briefingDate: briefingDate,
messages: messages ?? this.messages,
);
}
+41
View File
@@ -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),
);
}
+32 -1
View File
@@ -2,6 +2,9 @@ class Note {
final int id;
final String title;
final String body;
final List<String> tags;
final int? projectId;
final int? milestoneId;
final DateTime createdAt;
final DateTime updatedAt;
@@ -9,6 +12,9 @@ class Note {
required this.id,
required this.title,
required this.body,
required this.tags,
this.projectId,
this.milestoneId,
required this.createdAt,
required this.updatedAt,
});
@@ -17,6 +23,12 @@ 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() ??
[],
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 +36,32 @@ class Note {
Map<String, dynamic> toJson() => {
'title': title,
'body': body,
'tags': tags,
'project_id': projectId,
'milestone_id': milestoneId,
};
Note copyWith({String? title, String? body}) => Note(
Note copyWith({
String? title,
String? body,
List<String>? tags,
Object? projectId = _undefined,
Object? milestoneId = _undefined,
}) =>
Note(
id: id,
title: title ?? this.title,
body: body ?? this.body,
tags: tags ?? this.tags,
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();
}
+40
View File
@@ -0,0 +1,40 @@
class Project {
final int id;
final String title;
final String? description;
final String? goal;
final String status; // active | completed | archived
final String? color;
final DateTime createdAt;
final DateTime updatedAt;
const Project({
required this.id,
required this.title,
this.description,
this.goal,
required this.status,
this.color,
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?,
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,
};
}
+26
View File
@@ -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();
}
@@ -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);
}
+20 -4
View File
@@ -7,9 +7,25 @@ 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,
}) =>
_api.create(title, body, tags: tags, projectId: projectId);
Future<Note> update(
int id,
String title,
String body, {
List<String> tags = const [],
int? projectId,
bool clearProject = false,
}) =>
_api.update(id, title, body,
tags: tags, projectId: projectId, clearProject: clearProject);
Future<void> delete(int id) => _api.delete(id);
}
@@ -0,0 +1,21 @@
import '../api/projects_api.dart';
import '../models/project.dart';
class ProjectsRepository {
final ProjectsApi _api;
const ProjectsRepository(this._api);
Future<List<Project>> getAll({String? status}) => _api.getAll(status: status);
Future<Project> getOne(int id) => _api.getOne(id);
Future<Project> create({
required String title,
String? description,
String? goal,
String? color,
}) =>
_api.create(
title: title, description: description, goal: goal, color: color);
Future<Project> update(int id, Map<String, dynamic> fields) =>
_api.update(id, fields);
Future<void> delete(int id) => _api.delete(id);
}
@@ -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,12 @@ class TasksRepository {
status: status,
priority: priority,
dueDate: dueDate,
projectId: projectId,
parentId: parentId,
);
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
Future<Task> update(int id, Map<String, dynamic> fields) =>
_api.update(id, fields);
+25
View File
@@ -4,13 +4,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/api_client.dart';
import '../data/api/auth_api.dart';
import '../data/api/briefing_api.dart';
import '../data/api/chat_api.dart';
import '../data/api/milestones_api.dart';
import '../data/api/notes_api.dart';
import '../data/api/projects_api.dart';
import '../data/api/quick_capture_api.dart';
import '../data/api/tasks_api.dart';
import '../data/repositories/auth_repository.dart';
import '../data/repositories/chat_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 +50,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 +69,19 @@ 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 briefingApiProvider = Provider<BriefingApi>((ref) {
return BriefingApi(ref.watch(dioProvider));
});
+7 -10
View File
@@ -4,18 +4,15 @@ import 'api_client_provider.dart';
enum AuthStatus { unknown, authenticated, unauthenticated }
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();
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
} catch (_) {
@@ -24,14 +21,14 @@ class AuthNotifier extends StateNotifier<AuthStatus> {
}
Future<void> login(String username, String password) async {
final repo = _ref.read(authRepositoryProvider);
final repo = ref.read(authRepositoryProvider);
await repo.login(username, password);
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;
+119
View File
@@ -0,0 +1,119 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/briefing_conversation.dart';
import '../data/models/message.dart';
import 'api_client_provider.dart';
/// Drives the loading indicator in BriefingScreen's reply area.
final isBriefingStreamingProvider =
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
class _BoolNotifier extends Notifier<bool> {
@override
bool build() => false;
}
final briefingProvider =
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
BriefingNotifier.new);
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
@override
Future<BriefingConversation> build() async {
return ref.read(briefingApiProvider).getToday();
}
/// Trigger a briefing slot (e.g. "compilation") then reload.
Future<void> refresh(String slot) async {
await ref.read(briefingApiProvider).triggerSlot(slot);
ref.invalidateSelf();
await future;
}
/// Send a reply to today's briefing conversation.
///
/// Mirrors MessagesNotifier.sendMessage():
/// 1. Optimistic UI update
/// 2. POST message to chat endpoint
/// 3. SSE stream (best-effort)
/// 4. Poll until complete
Future<void> sendReply(String content) async {
final conv = state.value;
if (conv == null) return;
final convId = conv.id;
final chatApi = ref.read(chatApiProvider);
final previous = conv.messages;
final userMsg = Message(
conversationId: convId,
role: MessageRole.user,
content: content,
);
final placeholder = Message(
conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true;
try {
await chatApi.sendMessage(convId, content);
} catch (e) {
state = AsyncData(conv.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false;
rethrow;
}
// SSE stream (best-effort)
bool streamedContent = false;
try {
await for (final chunk in chatApi.streamGeneration(convId)) {
streamedContent = true;
final current = state.value;
if (current == null) break;
final msgs = current.messages;
if (msgs.isEmpty) continue;
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
}
} catch (_) {
// Fall through to polling.
}
// Poll until complete (max 20 attempts, 2s apart)
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await ref.read(briefingApiProvider).getMessages(convId);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final hasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh));
}
if (done) break;
}
} catch (_) {
// Clear the generating placeholder so UI doesn't spin forever.
final current = state.value;
if (current != null) {
final msgs = current.messages;
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData(current.copyWith(messages: [
...msgs.sublist(0, msgs.length - 1),
msgs.last.copyWith(status: 'complete'),
]));
}
}
} finally {
ref.read(isBriefingStreamingProvider.notifier).state = false;
}
}
}
+10 -6
View File
@@ -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,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart';
import 'api_client_provider.dart';
import 'capture_queue_provider.dart';
import 'notes_provider.dart';
import 'tasks_provider.dart';
/// 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.
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
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 {
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.",
);
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,
};
}
+29 -13
View File
@@ -4,8 +4,19 @@ import '../data/models/conversation.dart';
import '../data/models/message.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;
}
final conversationsProvider =
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
@@ -20,14 +31,14 @@ 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,
]);
}
@@ -35,7 +46,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
// 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 +55,26 @@ 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;
final convId = _convId;
final repo = ref.read(chatRepositoryProvider);
final previousMessages = state.valueOrNull ?? [];
final previousMessages = state.value ?? [];
// Optimistic UI: show the user message + assistant placeholder immediately.
final userMsg = Message(
@@ -132,7 +148,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),
+11
View File
@@ -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);
});
+28 -9
View File
@@ -12,18 +12,37 @@ 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,
}) async {
final note = await ref
.read(notesRepositoryProvider)
.create(title, body, tags: tags, projectId: projectId);
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,
}) async {
final updated = await ref.read(notesRepositoryProvider).update(
id,
title,
body,
tags: tags,
projectId: projectId,
clearProject: clearProject,
);
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 +51,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,
]);
}
+49
View File
@@ -0,0 +1,49 @@
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();
}
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,
]);
}
}
+28 -28
View File
@@ -15,16 +15,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 +36,49 @@ 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;
}
}
+5 -3
View File
@@ -18,6 +18,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
required TaskStatus status,
required TaskPriority priority,
DateTime? dueDate,
int? projectId,
}) async {
final task = await ref.read(tasksRepositoryProvider).create(
title: title,
@@ -25,15 +26,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 +44,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,
]);
}
+6 -6
View File
@@ -51,7 +51,9 @@ class UpdateNotifier extends Notifier<UpdateState> {
state = state.copyWith(status: UpdateStatus.checking);
try {
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
final currentVersion =
'${packageInfo.version}.${packageInfo.buildNumber}';
// Parse repo URL → Forgejo API endpoint
final uri = Uri.parse(repoUrl);
@@ -122,11 +124,9 @@ class UpdateNotifier extends Notifier<UpdateState> {
type: 'application/vnd.android.package-archive',
);
// Return to available so the user can retry install if they dismissed it.
state = state.copyWith(
status: UpdateStatus.available,
downloadProgress: 1.0,
);
// Transition to idle the system installer is now open.
// Going back to `available` would re-trigger the update dialog loop.
state = const UpdateState();
} catch (e) {
state = state.copyWith(
status: UpdateStatus.error,
+2 -2
View File
@@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
_usernameController.text.trim(),
_passwordController.text,
);
if (mounted) context.go(Routes.notes);
if (mounted) context.go(Routes.briefing);
} on AuthException catch (e) {
setState(() => _error = e.message);
} on AppException catch (e) {
@@ -90,7 +90,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
cookieJar: ref.read(cookieJarProvider),
onSuccess: () async {
await ref.read(authProvider.notifier).verify();
if (mounted) context.go(Routes.notes);
if (mounted) context.go(Routes.briefing);
},
),
));
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/briefing_conversation.dart';
import '../../data/models/message.dart';
import '../../providers/api_client_provider.dart';
import '../../widgets/chat_message_bubble.dart';
class BriefingHistoryScreen extends ConsumerWidget {
const BriefingHistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyAsync = ref.watch(_briefingHistoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('Past Briefings')),
body: historyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) =>
const Center(child: Text('Could not load briefing history.')),
data: (convs) {
if (convs.isEmpty) {
return const Center(child: Text('No past briefings.'));
}
return ListView.builder(
itemCount: convs.length,
itemBuilder: (context, i) {
final conv = convs[i];
final label = conv.briefingDate ?? conv.title;
return ListTile(
leading: const Icon(Icons.wb_sunny_outlined),
title: Text(label),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _BriefingDetailScreen(conv: conv),
),
),
);
},
);
},
),
);
}
}
/// Lazily loads and displays all messages for a past briefing.
class _BriefingDetailScreen extends ConsumerWidget {
final BriefingConversation conv;
const _BriefingDetailScreen({required this.conv});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesAsync = ref.watch(_briefingMessagesProvider(conv.id));
return Scaffold(
appBar: AppBar(title: Text(conv.briefingDate ?? conv.title)),
body: messagesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const Center(child: Text('Could not load messages.')),
data: (messages) {
if (messages.isEmpty) {
return const Center(child: Text('No messages.'));
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (_, i) => ChatMessageBubble(message: messages[i]),
);
},
),
);
}
}
// ── Private providers (scoped to this file) ──────────────────────────────────
final _briefingHistoryProvider =
FutureProvider<List<BriefingConversation>>((ref) async {
return ref.watch(briefingApiProvider).getHistory();
});
final _briefingMessagesProvider =
FutureProvider.family<List<Message>, int>((ref, convId) async {
return ref.watch(briefingApiProvider).getMessages(convId);
});
+299
View File
@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/briefing_provider.dart';
import '../../widgets/briefing_digest_card.dart';
import '../../widgets/chat_message_bubble.dart';
import 'briefing_history_screen.dart';
class BriefingScreen extends ConsumerStatefulWidget {
const BriefingScreen({super.key});
@override
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
}
class _BriefingScreenState extends ConsumerState<BriefingScreen> {
final _controller = TextEditingController();
final _scrollController = ScrollController();
bool _refreshing = false;
@override
void dispose() {
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
Future<void> _sendReply() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
try {
await ref.read(briefingProvider.notifier).sendReply(text);
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to send reply.')),
);
}
}
}
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
await ref.read(briefingProvider.notifier).refresh('compilation');
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not generate briefing.')),
);
}
} finally {
if (mounted) setState(() => _refreshing = false);
}
}
@override
Widget build(BuildContext context) {
final briefingAsync = ref.watch(briefingProvider);
final isStreaming = ref.watch(isBriefingStreamingProvider);
final scheme = Theme.of(context).colorScheme;
// Scroll to bottom when messages change
ref.listen(briefingProvider, (_, _) => _scrollToBottom());
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Briefing', style: Theme.of(context).textTheme.titleLarge),
Text(
_todayLabel(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
actions: [
if (_refreshing)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton(
icon: const Icon(Icons.refresh_outlined),
tooltip: 'Generate briefing',
onPressed: _refresh,
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'history') {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const BriefingHistoryScreen(),
));
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'history',
child: Text('View past briefings'),
),
],
),
],
),
body: briefingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Could not load today's briefing."),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(briefingProvider),
child: const Text('Retry'),
),
],
),
),
data: (conv) {
// First assistant message for the digest card (null if none yet)
final Message? firstAssistant = conv.messages
.where((m) => m.role == MessageRole.assistant)
.toList()
.firstOrNull;
return Column(
children: [
// Digest card header
BriefingDigestCard(
message: firstAssistant,
onGenerateNow: _refresh,
),
// Divider + label
if (conv.messages.isNotEmpty) ...[
const SizedBox(height: 4),
Row(children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'Conversation',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const Expanded(child: Divider()),
]),
],
// Message list
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
itemCount: conv.messages.length,
itemBuilder: (_, i) =>
ChatMessageBubble(message: conv.messages[i]),
),
),
// Progress bar while streaming
if (isStreaming)
LinearProgressIndicator(
minHeight: 2,
color: scheme.primary,
),
// Reply bar
const Divider(height: 1),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Reply to your briefing…',
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.newline,
enabled: !isStreaming,
),
),
const SizedBox(width: 8),
_GradientSendButton(
onPressed: isStreaming ? null : _sendReply,
isStreaming: isStreaming,
),
],
),
),
),
],
);
},
),
);
}
String _todayLabel() {
final now = DateTime.now();
const days = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'
];
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
}
}
class _GradientSendButton extends StatelessWidget {
final VoidCallback? onPressed;
final bool isStreaming;
const _GradientSendButton({
required this.onPressed,
required this.isStreaming,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final disabled = onPressed == null;
return DecoratedBox(
decoration: BoxDecoration(
gradient: disabled
? null
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
),
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
borderRadius: BorderRadius.circular(10),
),
child: IconButton(
icon: isStreaming
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(
Icons.send,
color: disabled
? scheme.onSurface.withValues(alpha: 0.38)
: Colors.white,
),
onPressed: onPressed,
),
);
}
}
+3 -45
View File
@@ -1,12 +1,9 @@
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 '../../widgets/chat_message_bubble.dart';
class ChatScreen extends ConsumerStatefulWidget {
final int conversationId;
@@ -73,7 +70,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final convTitle = ref
.watch(conversationsProvider)
.valueOrNull
.value
?.where((c) => c.id == widget.conversationId)
.firstOrNull
?.title;
@@ -102,7 +99,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (context, i) =>
_MessageBubble(message: messages[i]),
ChatMessageBubble(message: messages[i]),
);
},
),
@@ -153,42 +150,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,126 @@
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';
class ConversationsTabScreen extends ConsumerWidget {
const ConversationsTabScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final convsAsync = ref.watch(conversationsProvider);
return Scaffold(
appBar: AppBar(
title: Text('Chat', style: theme.textTheme.titleLarge),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'New conversation',
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
),
],
),
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: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(conversationsProvider),
child: ListView.builder(
itemCount: convs.length,
itemBuilder: (ctx, i) {
final c = convs[i];
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(c.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
_relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
_confirmDelete(context, ref, c.id, c.title),
),
onTap: () =>
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
);
},
),
);
},
),
);
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete')),
],
),
);
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
}
}
}
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}';
}
+294
View File
@@ -0,0 +1,294 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/models/note.dart';
import '../../data/models/task.dart';
import '../../providers/notes_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../widgets/library_item_card.dart';
enum _LibraryFilter { all, notes, tasks, projects }
enum _TaskStatusFilter { all, todo, inProgress, done }
class LibraryScreen extends ConsumerStatefulWidget {
const LibraryScreen({super.key});
@override
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
_LibraryFilter _filter = _LibraryFilter.all;
_TaskStatusFilter _taskStatus = _TaskStatusFilter.all;
bool _searchActive = false;
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
bool _matchesSearch(String text) {
if (_searchQuery.isEmpty) return true;
return text.toLowerCase().contains(_searchQuery.toLowerCase());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final notesAsync = ref.watch(notesProvider);
final tasksAsync = ref.watch(tasksProvider);
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
appBar: AppBar(
title: _searchActive
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search…',
border: InputBorder.none,
isDense: true,
),
onChanged: (q) => setState(() => _searchQuery = q),
)
: Text('Library', style: theme.textTheme.titleLarge),
actions: [
IconButton(
icon: Icon(_searchActive ? Icons.close : Icons.search),
onPressed: () => setState(() {
_searchActive = !_searchActive;
if (!_searchActive) {
_searchQuery = '';
_searchController.clear();
}
}),
),
],
),
body: Column(
children: [
// ── Filter pills ──────────────────────────────────────────────────
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row(
children: _LibraryFilter.values.map((f) {
final label = switch (f) {
_LibraryFilter.all => 'All',
_LibraryFilter.notes => 'Notes',
_LibraryFilter.tasks => 'Tasks',
_LibraryFilter.projects => 'Projects',
};
final selected = _filter == f;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(label),
selected: selected,
onSelected: (_) => setState(() {
_filter = f;
_taskStatus = _TaskStatusFilter.all;
}),
selectedColor:
theme.colorScheme.primary.withValues(alpha: 0.18),
checkmarkColor: theme.colorScheme.primary,
),
);
}).toList(),
),
),
// ── Task status sub-filter (Tasks pill only) ───────────────────
if (_filter == _LibraryFilter.tasks)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Row(
children: _TaskStatusFilter.values.map((s) {
final label = switch (s) {
_TaskStatusFilter.all => 'All',
_TaskStatusFilter.todo => 'To Do',
_TaskStatusFilter.inProgress => 'In Progress',
_TaskStatusFilter.done => 'Done',
};
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: _taskStatus == s,
onSelected: (_) => setState(() => _taskStatus = s),
selectedColor:
theme.colorScheme.secondary.withValues(alpha: 0.15),
),
);
}).toList(),
),
),
const Divider(height: 1),
// ── Content ───────────────────────────────────────────────────────
Expanded(
child: switch (_filter) {
_LibraryFilter.notes => _buildNotesList(notesAsync),
_LibraryFilter.tasks => _buildTasksList(tasksAsync),
_LibraryFilter.projects => _buildProjectsList(projectsAsync),
_LibraryFilter.all => _buildAllList(notesAsync, tasksAsync),
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateSheet(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildNotesList(AsyncValue<List<Note>> notesAsync) {
return notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (notes) {
final filtered = notes
.where((n) => _matchesSearch(n.title) || _matchesSearch(n.body))
.toList();
if (filtered.isEmpty) {
return const Center(child: Text('No notes found'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(notesProvider),
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]),
),
);
},
);
}
Widget _buildTasksList(AsyncValue<List<Task>> tasksAsync) {
return tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (tasks) {
var filtered = tasks.where((t) => _matchesSearch(t.title)).toList();
if (_taskStatus != _TaskStatusFilter.all) {
final status = switch (_taskStatus) {
_TaskStatusFilter.todo => TaskStatus.todo,
_TaskStatusFilter.inProgress => TaskStatus.inProgress,
_TaskStatusFilter.done => TaskStatus.done,
_ => TaskStatus.todo,
};
filtered = filtered.where((t) => t.status == status).toList();
}
if (filtered.isEmpty) {
return const Center(child: Text('No tasks found'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(tasksProvider),
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]),
),
);
},
);
}
Widget _buildProjectsList(AsyncValue<List<dynamic>> projectsAsync) {
return projectsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (projects) {
if (projects.isEmpty) {
return const Center(child: Text('No projects'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(projectsProvider),
child: ListView.builder(
itemCount: projects.length,
itemBuilder: (_, i) =>
ProjectLibraryCard(project: projects[i]),
),
);
},
);
}
Widget _buildAllList(
AsyncValue<List<Note>> notesAsync,
AsyncValue<List<Task>> tasksAsync,
) {
final notes = notesAsync.value ?? [];
final tasks = tasksAsync.value ?? [];
// Merge and sort by updatedAt desc
final items = <(DateTime, Widget)>[];
for (final n in notes) {
if (_matchesSearch(n.title) || _matchesSearch(n.body)) {
items.add((n.updatedAt, NoteLibraryCard(note: n)));
}
}
for (final t in tasks) {
if (_matchesSearch(t.title)) {
items.add((t.updatedAt, TaskLibraryCard(task: t)));
}
}
items.sort((a, b) => b.$1.compareTo(a.$1));
if (notesAsync.isLoading || tasksAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (items.isEmpty) {
return const Center(child: Text('Nothing here yet'));
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(notesProvider);
ref.invalidate(tasksProvider);
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => items[i].$2,
),
);
}
void _showCreateSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.article_outlined),
title: const Text('New note'),
onTap: () {
Navigator.pop(context);
context.push(Routes.noteNew);
},
),
ListTile(
leading: const Icon(Icons.check_box_outlined),
title: const Text('New task'),
onTap: () {
Navigator.pop(context);
context.push(Routes.taskNew);
},
),
],
),
),
);
}
}
+2 -2
View File
@@ -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(
+109 -7
View File
@@ -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,6 +7,7 @@ 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;
@@ -19,10 +20,12 @@ 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;
// Future is created once in initState so FutureBuilder never restarts it.
late final Future<void> _initFuture;
@override
@@ -36,6 +39,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
void dispose() {
_titleController.dispose();
_contentController.dispose();
_tagController.dispose();
super.dispose();
}
@@ -44,6 +48,24 @@ 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;
}
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 +103,22 @@ 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,
);
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,
);
if (mounted) context.pop();
}
} on AppException catch (e) {
@@ -147,7 +179,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 +225,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'[, ]+$'), ''));
}
},
),
),
],
);
}
}
-216
View File
@@ -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),
),
),
],
);
}
}
@@ -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'),
),
],
),
),
);
}
}
+19 -5
View File
@@ -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;
}
+1 -1
View File
@@ -30,7 +30,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
if (!mounted) return;
final status = ref.read(authProvider);
if (status == AuthStatus.authenticated) {
context.go(Routes.notes);
context.go(Routes.briefing);
} else {
context.go(Routes.login);
}
+236 -68
View File
@@ -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}'),
),
);
}
}
-199
View File
@@ -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);
}
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart';
class BriefingDigestCard extends StatefulWidget {
/// The first assistant message from today's briefing, or null if none yet.
final Message? message;
/// Called when the user taps "Generate now".
final VoidCallback? onGenerateNow;
const BriefingDigestCard({
super.key,
required this.message,
this.onGenerateNow,
});
@override
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
}
class _BriefingDigestCardState extends State<BriefingDigestCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Row(
children: [
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Text(
_todayLabel(),
style: textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 10),
// Body
if (widget.message == null) ...[
Text(
'No briefing yet today.',
style: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
if (widget.onGenerateNow != null)
FilledButton.tonal(
onPressed: widget.onGenerateNow,
child: const Text('Generate now'),
),
] else ...[
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: _expanded
? MarkdownBody(data: widget.message!.content)
: _TruncatedMarkdown(
data: widget.message!.content,
maxLines: 5,
),
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Text(
_expanded ? 'Show less ↑' : 'Show more ↓',
style: TextStyle(
color: scheme.primary,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
);
}
String _todayLabel() {
final now = DateTime.now();
const days = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'
];
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
}
}
/// Renders Markdown truncated to [maxLines] visible lines.
class _TruncatedMarkdown extends StatelessWidget {
final String data;
final int maxLines;
const _TruncatedMarkdown({required this.data, required this.maxLines});
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxLines * 20.0),
child: ClipRect(child: MarkdownBody(data: data)),
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'dart:math' show min;
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart';
class ChatMessageBubble extends StatelessWidget {
final Message message;
const ChatMessageBubble({super.key, required this.message});
@override
Widget build(BuildContext context) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
final isGenerating = message.status == 'generating';
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(
// Ghost style: transparent bg, thin border
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(
// Assistant: elevated surface + left accent border
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: isGenerating && message.content.isEmpty
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onSurfaceVariant,
),
)
: MarkdownBody(
data: message.content.isEmpty ? '' : message.content,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? scheme.onSurface.withValues(alpha: 0.75)
: scheme.onSurface,
fontSize: 14,
),
),
),
),
),
);
}
}
+271
View File
@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/constants.dart';
import '../data/models/note.dart';
import '../data/models/project.dart';
import '../data/models/task.dart';
import '../providers/tasks_provider.dart';
// ── Note card ────────────────────────────────────────────────────────────────
class NoteLibraryCard extends StatelessWidget {
final Note note;
const NoteLibraryCard({super.key, required this.note});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => context
.push(Routes.noteDetail.replaceFirst(':id', '${note.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.article_outlined,
size: 15, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(
note.title.isNotEmpty ? note.title : 'Untitled',
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
_relativeTime(note.updatedAt),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
],
),
if (note.body.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
note.body.replaceAll('\n', ' '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (note.tags.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 2,
children: note.tags
.take(4)
.map((t) => Chip(
label: Text(t),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
))
.toList(),
),
],
],
),
),
),
);
}
}
// ── Task card ────────────────────────────────────────────────────────────────
class TaskLibraryCard extends ConsumerWidget {
final Task task;
const TaskLibraryCard({super.key, required this.task});
Color _priorityColor(BuildContext context) {
return switch (task.priority) {
TaskPriority.high => const Color(0xFFEF4444),
TaskPriority.medium => const Color(0xFFF59E0B),
_ => Theme.of(context).colorScheme.onSurfaceVariant,
};
}
IconData get _statusIcon => switch (task.status) {
TaskStatus.done => Icons.check_circle,
TaskStatus.inProgress => Icons.timelapse,
_ => Icons.radio_button_unchecked,
};
Color _statusColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (task.status) {
TaskStatus.done => const Color(0xFF22C55E),
TaskStatus.inProgress => cs.primary,
_ => cs.onSurfaceVariant,
};
}
TaskStatus get _nextStatus => switch (task.status) {
TaskStatus.todo => TaskStatus.inProgress,
TaskStatus.inProgress => TaskStatus.done,
_ => TaskStatus.todo,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 14, 10),
child: Row(
children: [
// Status cycle button
IconButton(
icon: Icon(_statusIcon, color: _statusColor(context)),
onPressed: () => ref
.read(tasksProvider.notifier)
.updateTask(task.id, {'status': _nextStatus.value}),
tooltip: 'Cycle status',
visualDensity: VisualDensity.compact,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: task.status == TaskStatus.done
? TextDecoration.lineThrough
: null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (task.dueDate != null) ...[
const SizedBox(height: 2),
Text(
'Due ${_formatDate(task.dueDate!)}',
style: theme.textTheme.labelSmall?.copyWith(
color: task.dueDate!.isBefore(DateTime.now()) &&
task.status != TaskStatus.done
? const Color(0xFFEF4444)
: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (task.priority != TaskPriority.none &&
task.priority != TaskPriority.low)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _priorityColor(context),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
}
// ── Project card ─────────────────────────────────────────────────────────────
class ProjectLibraryCard extends StatelessWidget {
final Project project;
const ProjectLibraryCard({super.key, required this.project});
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = _parseColor(project.color);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {}, // No project detail screen in this app
borderRadius: BorderRadius.circular(14),
child: Row(
children: [
// Colour strip
Container(width: 6, height: 64, color: color),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.title,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (project.description?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
project.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
const SizedBox(width: 12),
],
),
),
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'just now';
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
if (diff.inDays < 1) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return _formatDate(dt);
}
String _formatDate(DateTime dt) {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
return '${months[dt.month - 1]} ${dt.day}';
}
+56
View File
@@ -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,
);
},
);
}
}
+218 -26
View File
@@ -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:
@@ -49,6 +65,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 +105,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 +121,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 +149,26 @@ 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"
fake_async:
dependency: transitive
description:
@@ -238,22 +278,22 @@ 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
@@ -264,6 +304,14 @@ packages:
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 +324,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 +352,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 +376,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.0"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
json_annotation:
dependency: transitive
description:
@@ -372,10 +444,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 +476,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 +560,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:
@@ -576,6 +664,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:
@@ -596,10 +692,10 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.2.1"
shared_preferences:
dependency: "direct main"
description:
@@ -656,11 +752,59 @@ 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"
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:
@@ -709,14 +853,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:
@@ -749,6 +909,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 +925,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:
+6 -5
View File
@@ -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,19 @@ 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
flutter_inappwebview: ^6.1.5
flutter_markdown_plus: ^1.0.7
google_fonts: ^8.0.2
dev_dependencies:
flutter_test: