Compare commits

...

34 Commits

Author SHA1 Message Date
bvandeusen 63e01389e8 Merge pull request 'dev to main: big fix and improvements' (#6) from dev into main
Reviewed-on: #6
2026-03-12 21:44:43 -04:00
bvandeusen 5ca5856f15 Project task view: tapping a project shows milestone-grouped task list
- ProjectLibraryCard.onTap navigates to /projects/:id/tasks
- New ProjectTasksScreen: milestone sections with coloured dot,
  done/total counter and thin progress bar; unassigned tasks at bottom
- Status cycle icon updates optimistically (pendingStatus map) so
  the list doesn't flash on every tap; syncs global tasksProvider
- New route /projects/:id/tasks registered in app.dart
- TasksApi.getByProject + TasksRepository + projectTasksProvider (family)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:40:14 -04:00
bvandeusen a337c3fda3 Fix update dialog: show errors, handle OpenFile result correctly
- Check OpenResult.type before resetting state — previously a failed
  open_file call (e.g. installer blocked on emulator) silently called
  state = const UpdateState(), nulling latestVersion and downloadUrl,
  causing "Version null" and a non-functional Download button
- Show errorMessage in dialog with error colour so failures are visible
- Guard Download button on downloadUrl != null (no button if URL lost)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:01:01 -04:00
bvandeusen fc1c7cade2 chore: rename APK output to Fabled-<buildNumber>.apk
e.g. Fabled-2603122.apk for tag v26.03.12.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:52:39 -04:00
bvandeusen 0971433c7a refactor: remove briefing digest card, show full message list directly
The summary card duplicated the first assistant message and consumed
screen real estate without benefit on small screens. Replaced with:
- Full message list via ChatMessageBubble from the top
- SliverFillRemaining empty state ("No briefing yet today" + generate button)
  when no messages exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 19:08:21 -04:00
bvandeusen 844f68d376 fix: update loop, ghost queue item, briefing card scroll trap
- Update dialog no longer re-appears on every shell re-mount; check()
  is skipped if status is already available/upToDate/downloading
- Offline queue dequeue now happens before the mounted check, preventing
  ghost items when the widget disposes mid-drain
- Briefing digest card and messages now share a CustomScrollView so
  expanding the card doesn't trap the user without scroll access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:23:24 -04:00
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
47 changed files with 6201 additions and 1727 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
+8 -6
View File
@@ -37,11 +37,13 @@ android {
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
if (keystorePropertiesFile.exists()) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
@@ -59,7 +61,7 @@ android {
val variant = this
outputs.all {
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
output?.outputFileName = "Fabled-${variant.versionName}.${variant.versionCode}.apk"
output?.outputFileName = "Fabled-${variant.versionCode}.apk"
}
}
}
@@ -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
+154 -169
View File
@@ -4,27 +4,27 @@ 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/milestones_provider.dart';
import 'providers/capture_work_queue_provider.dart';
import 'providers/notes_provider.dart';
import 'providers/projects_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/library/project_tasks_screen.dart';
import 'screens/chat/chat_screen.dart';
import 'screens/chat/conversations_list_screen.dart';
import 'screens/chat/conversations_tab_screen.dart';
import 'screens/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/projects/project_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
@@ -108,6 +108,12 @@ final routerProvider = Provider<GoRouter>((ref) {
taskId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.projectTasks,
builder: (_, state) => ProjectTasksScreen(
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.chat,
builder: (_, state) => ChatScreen(
@@ -118,20 +124,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(),
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectListScreen(),
path: Routes.library,
builder: (_, _) => const LibraryScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsListScreen(),
builder: (_, _) => const ConversationsTabScreen(),
),
],
),
@@ -148,15 +150,23 @@ class _Shell extends ConsumerStatefulWidget {
}
class _ShellState extends ConsumerState<_Shell> {
static const _tabs = [Routes.notes, Routes.tasks, Routes.projects, Routes.conversations];
static const _tabs = [
Routes.briefing,
Routes.library,
Routes.conversations,
];
@override
void initState() {
super.initState();
// Silent update check on first app load.
// Silent update check — only if we haven't already checked this session.
// Skipping when status is not idle/error prevents the dialog from
// re-appearing every time the shell re-mounts (e.g. after visiting settings).
WidgetsBinding.instance.addPostFrameCallback((_) {
final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) {
if (repoUrl == null || repoUrl.isEmpty) return;
final status = ref.read(updateProvider).status;
if (status == UpdateStatus.idle || status == UpdateStatus.error) {
ref.read(updateProvider.notifier).check(repoUrl);
}
});
@@ -182,7 +192,7 @@ class _ShellState extends ConsumerState<_Shell> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Version ${state.latestVersion} is ready to install.'),
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
if (state.currentVersion != null)
Text(
'Installed: v${state.currentVersion}',
@@ -202,6 +212,16 @@ class _ShellState extends ConsumerState<_Shell> {
style: Theme.of(context).textTheme.bodySmall,
),
],
if (state.status == UpdateStatus.error &&
state.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
state.errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
actions: [
@@ -209,7 +229,7 @@ class _ShellState extends ConsumerState<_Shell> {
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Later'),
),
if (!isDownloading)
if (!isDownloading && state.downloadUrl != null)
FilledButton(
onPressed: () => ref
.read(updateProvider.notifier)
@@ -236,8 +256,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) {
@@ -255,19 +273,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'),
),
NavigationRailDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: Text('Projects'),
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: Text('Library'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
@@ -291,18 +304,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.folder), label: 'Projects'),
NavigationDestination(
icon: Icon(Icons.chat_bubble), label: 'Chat'),
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
],
),
);
@@ -318,13 +347,11 @@ class _QuickCaptureBar extends ConsumerStatefulWidget {
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController();
bool _busy = false;
@override
void initState() {
super.initState();
// Retry any offline-queued captures from previous sessions.
WidgetsBinding.instance.addPostFrameCallback((_) => _drainQueue());
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
}
@override
@@ -333,63 +360,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);
ref.invalidate(projectsProvider);
ref.invalidate(projectMilestonesProvider);
}
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
messenger.showSnackBar(
SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
);
_drainQueue(); // Silently flush any offline-queued captures.
} on NetworkException {
await ref.read(captureQueueProvider.notifier).enqueue(text);
messenger.showSnackBar(
const SnackBar(
content: Text(
"You're offline — capture saved and will retry automatically."),
behavior: SnackBarBehavior.floating,
),
);
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(e.message), behavior: SnackBarBehavior.floating),
);
} catch (_) {
messenger.showSnackBar(
const SnackBar(
content: Text('Capture failed. Please try again.'),
behavior: SnackBarBehavior.floating,
),
);
} finally {
if (mounted) setState(() => _busy = false);
}
setState(() {}); // clear suffix icon
ref.read(captureWorkQueueProvider.notifier).enqueue(text);
}
Future<void> _drainQueue() async {
Future<void> _drainOfflineQueue() async {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
@@ -398,36 +377,30 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
if (!mounted) break;
try {
final result = await api.capture(text);
if (!mounted) break;
// Dequeue before the mounted check — SharedPreferences doesn't need
// the widget alive, and skipping this would leave a ghost item.
await ref.read(captureQueueProvider.notifier).dequeue(text);
if (!mounted) break;
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
ref.invalidate(projectsProvider);
ref.invalidate(projectMilestonesProvider);
}
} on NetworkException {
break; // Still offline — stop draining.
break;
} catch (_) {
// Server/parse error — remove to avoid infinite retries.
if (mounted) await ref.read(captureQueueProvider.notifier).dequeue(text);
// Server error or unexpected failure — drop from queue to prevent
// ghost items that can never be cleared.
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…';
}
@@ -435,61 +408,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),
],
),
);
}
@@ -506,17 +500,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
@@ -14,4 +14,7 @@ abstract class Routes {
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const library = '/library';
static const projectTasks = '/projects/:id/tasks';
}
+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);
}
}
}
+18
View File
@@ -52,6 +52,24 @@ class TasksApi {
}
}
Future<List<Task>> getByProject(int projectId) async {
try {
final response = await _dio.get(
'/api/notes',
queryParameters: {
'project_id': projectId,
'is_task': 'true',
'limit': 500,
},
);
final data = response.data as Map<String, dynamic>;
final list = data['notes'] as List<dynamic>;
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<List<Task>> getSubTasks(int parentId) async {
try {
final response = await _dio.get(
@@ -0,0 +1,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,
);
}
@@ -27,6 +27,7 @@ class TasksRepository {
parentId: parentId,
);
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
Future<Task> update(int id, Map<String, dynamic> fields) =>
+5
View File
@@ -4,6 +4,7 @@ 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';
@@ -80,3 +81,7 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
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),
+3 -3
View File
@@ -21,7 +21,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
final note = await ref
.read(notesRepositoryProvider)
.create(title, body, tags: tags, projectId: projectId);
state = AsyncData([...state.valueOrNull ?? [], note]);
state = AsyncData([...state.value ?? [], note]);
return note;
}
@@ -42,7 +42,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
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;
@@ -51,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,
]);
}
+3 -3
View File
@@ -25,7 +25,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
goal: goal,
color: color,
);
state = AsyncData([...state.valueOrNull ?? [], project]);
state = AsyncData([...state.value ?? [], project]);
return project;
}
@@ -33,7 +33,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
final updated =
await ref.read(projectsRepositoryProvider).update(id, fields);
state = AsyncData([
for (final p in state.valueOrNull ?? [])
for (final p in state.value ?? [])
if (p.id == id) updated else p,
]);
return updated;
@@ -42,7 +42,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
Future<void> delete(int id) async {
await ref.read(projectsRepositoryProvider).delete(id);
state = AsyncData([
for (final p in state.valueOrNull ?? [])
for (final p in state.value ?? [])
if (p.id != id) p,
]);
}
+28 -29
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,48 +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) ??
'https://git.fabledsword.com/bvandeusen/FabledApp');
class ForgejoRepoUrlNotifier extends Notifier<String?> {
@override
String? build() {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getString(_kForgejoRepoUrl) ??
'https://git.fabledsword.com/bvandeusen/FabledApp';
}
Future<void> setUrl(String url) async {
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
await _prefs.setString(_kForgejoRepoUrl, clean);
await ref.read(sharedPreferencesProvider).setString(_kForgejoRepoUrl, clean);
state = clean;
}
}
final serverUrlProvider = StateNotifierProvider<ServerUrlNotifier, String?>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return ServerUrlNotifier(prefs);
});
final serverUrlProvider =
NotifierProvider<ServerUrlNotifier, String?>(ServerUrlNotifier.new);
class ServerUrlNotifier extends StateNotifier<String?> {
final SharedPreferences _prefs;
ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl));
class ServerUrlNotifier extends Notifier<String?> {
@override
String? build() {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getString(_kServerUrl);
}
Future<void> setUrl(String url) async {
// Strip trailing slash
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
await _prefs.setString(_kServerUrl, clean);
await ref.read(sharedPreferencesProvider).setString(_kServerUrl, clean);
state = clean;
}
Future<void> clear() async {
await _prefs.remove(_kServerUrl);
await ref.read(sharedPreferencesProvider).remove(_kServerUrl);
state = null;
}
}
+8 -3
View File
@@ -6,6 +6,11 @@ import 'api_client_provider.dart';
final tasksProvider =
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
final projectTasksProvider =
FutureProvider.family<List<Task>, int>((ref, projectId) {
return ref.watch(tasksRepositoryProvider).getByProject(projectId);
});
class TasksNotifier extends AsyncNotifier<List<Task>> {
@override
Future<List<Task>> build() async {
@@ -28,14 +33,14 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
dueDate: dueDate,
projectId: projectId,
);
state = AsyncData([...state.valueOrNull ?? [], task]);
state = AsyncData([...state.value ?? [], task]);
return task;
}
Future<Task> updateTask(int id, Map<String, dynamic> fields) async {
final updated = await ref.read(tasksRepositoryProvider).update(id, fields);
state = AsyncData([
for (final t in state.valueOrNull ?? [])
for (final t in state.value ?? [])
if (t.id == id) updated else t,
]);
return updated;
@@ -44,7 +49,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,
]);
}
+10 -6
View File
@@ -119,16 +119,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
},
);
await OpenFile.open(
final result = await OpenFile.open(
path,
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,
);
if (result.type == ResultType.done) {
// Installer launched — reset to idle so the dialog closes naturally.
state = const UpdateState();
} else {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage: 'Could not open installer: ${result.message}',
);
}
} catch (e) {
state = state.copyWith(
status: UpdateStatus.error,
+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);
});
+295
View File
@@ -0,0 +1,295 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../providers/briefing_provider.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) {
return Column(
children: [
Expanded(
child: CustomScrollView(
controller: _scrollController,
slivers: [
if (conv.messages.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'No briefing yet today.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('Generate now'),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
sliver: SliverList.builder(
itemCount: conv.messages.length,
itemBuilder: (_, i) =>
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_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/chat_provider.dart';
import '../../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);
},
),
],
),
),
);
}
}
@@ -0,0 +1,381 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/models/milestone.dart';
import '../../data/models/project.dart';
import '../../data/models/task.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/milestones_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
class ProjectTasksScreen extends ConsumerStatefulWidget {
final int projectId;
const ProjectTasksScreen({super.key, required this.projectId});
@override
ConsumerState<ProjectTasksScreen> createState() => _ProjectTasksScreenState();
}
class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
// Local optimistic status overrides — avoids a reload flash on every cycle tap.
final Map<int, TaskStatus> _pendingStatus = {};
TaskStatus _effectiveStatus(Task task) =>
_pendingStatus[task.id] ?? task.status;
TaskStatus _nextStatus(TaskStatus s) => switch (s) {
TaskStatus.todo => TaskStatus.inProgress,
TaskStatus.inProgress => TaskStatus.done,
TaskStatus.done => TaskStatus.todo,
};
Future<void> _cycleStatus(Task task) async {
final current = _effectiveStatus(task);
final next = _nextStatus(current);
setState(() => _pendingStatus[task.id] = next);
try {
await ref
.read(tasksRepositoryProvider)
.update(task.id, {'status': next.value});
// Sync the global tasks list so the library view stays consistent.
ref.invalidate(tasksProvider);
} catch (_) {
// Revert optimistic change on error.
setState(() => _pendingStatus.remove(task.id));
}
}
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
}
}
@override
Widget build(BuildContext context) {
final tasksAsync = ref.watch(projectTasksProvider(widget.projectId));
final milestonesAsync = ref.watch(projectMilestonesProvider(widget.projectId));
final project = ref.watch(projectsProvider).value
?.whereType<Project>()
.where((p) => p.id == widget.projectId)
.firstOrNull;
final color = _parseColor(project?.color);
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project?.title ?? 'Project',
style: Theme.of(context).textTheme.titleLarge,
),
if (project?.description?.isNotEmpty == true)
Text(
project!.description!,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
body: tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading tasks: $e')),
data: (tasks) => _buildBody(context, tasks, milestonesAsync, color),
),
);
}
Widget _buildBody(
BuildContext context,
List<Task> tasks,
AsyncValue<List<Milestone>> milestonesAsync,
Color color,
) {
final milestones = (milestonesAsync.value ?? []).toList()
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
// Group tasks by milestoneId.
final byMilestone = <int?, List<Task>>{};
for (final t in tasks) {
(byMilestone[t.milestoneId] ??= []).add(t);
}
final unassigned = byMilestone[null] ?? [];
if (tasks.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_box_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
'No tasks in this project yet.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
setState(() => _pendingStatus.clear());
ref.invalidate(projectTasksProvider(widget.projectId));
ref.invalidate(projectMilestonesProvider(widget.projectId));
},
child: CustomScrollView(
slivers: [
// Top colour strip.
SliverToBoxAdapter(child: Container(height: 4, color: color)),
// Milestone sections.
for (final ms in milestones) ...[
SliverToBoxAdapter(
child: _MilestoneHeader(
milestone: ms,
tasks: byMilestone[ms.id] ?? [],
color: color,
),
),
if ((byMilestone[ms.id] ?? []).isNotEmpty)
SliverList.builder(
itemCount: byMilestone[ms.id]!.length,
itemBuilder: (_, i) {
final task = byMilestone[ms.id]![i];
return _TaskRow(
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
);
},
),
],
// Unassigned tasks.
if (unassigned.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
child: Text(
'No milestone',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
),
),
SliverList.builder(
itemCount: unassigned.length,
itemBuilder: (_, i) {
final task = unassigned[i];
return _TaskRow(
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
);
},
),
],
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
);
}
}
// ── Milestone section header ──────────────────────────────────────────────────
class _MilestoneHeader extends StatelessWidget {
final Milestone milestone;
final List<Task> tasks;
final Color color;
const _MilestoneHeader({
required this.milestone,
required this.tasks,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final done = tasks.where((t) => t.status == TaskStatus.done).length;
final total = tasks.length;
final pct = total > 0 ? done / total : 0.0;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Expanded(
child: Text(
milestone.title,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'$done / $total',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
if (total > 0) ...[
const SizedBox(height: 4),
LinearProgressIndicator(
value: pct,
minHeight: 2,
color: color,
backgroundColor: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(1),
),
],
],
),
);
}
}
// ── Task row ──────────────────────────────────────────────────────────────────
class _TaskRow extends StatelessWidget {
final Task task;
final TaskStatus effectiveStatus;
final VoidCallback onStatusTap;
const _TaskRow({
required this.task,
required this.effectiveStatus,
required this.onStatusTap,
});
IconData get _statusIcon => switch (effectiveStatus) {
TaskStatus.done => Icons.check_circle,
TaskStatus.inProgress => Icons.timelapse,
TaskStatus.todo => Icons.radio_button_unchecked,
};
Color _statusColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (effectiveStatus) {
TaskStatus.done => const Color(0xFF22C55E),
TaskStatus.inProgress => cs.primary,
TaskStatus.todo => cs.onSurfaceVariant,
};
}
Color _priorityColor(BuildContext context) => switch (task.priority) {
TaskPriority.high => const Color(0xFFEF4444),
TaskPriority.medium => const Color(0xFFF59E0B),
_ => Colors.transparent,
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
child: Row(
children: [
IconButton(
icon: Icon(_statusIcon, color: _statusColor(context)),
onPressed: onStatusTap,
tooltip: 'Cycle status',
visualDensity: VisualDensity.compact,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: effectiveStatus == TaskStatus.done
? TextDecoration.lineThrough
: null,
color: effectiveStatus == TaskStatus.done
? theme.colorScheme.onSurfaceVariant
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (task.dueDate != null) ...[
const SizedBox(height: 2),
Text(
'Due ${_formatDate(task.dueDate!)}',
style: theme.textTheme.labelSmall?.copyWith(
color: task.dueDate!.isBefore(DateTime.now()) &&
effectiveStatus != TaskStatus.done
? const Color(0xFFEF4444)
: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (task.priority == TaskPriority.high ||
task.priority == TaskPriority.medium)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _priorityColor(context),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
}
String _formatDate(DateTime dt) {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return '${months[dt.month - 1]} ${dt.day}';
}
+1 -1
View File
@@ -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(
-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,631 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/exceptions.dart';
import '../../data/models/milestone.dart';
import '../../data/models/project.dart';
import '../../data/models/task.dart';
import '../../providers/milestones_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
class ProjectListScreen extends ConsumerWidget {
const ProjectListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Projects')),
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateDialog(context, ref),
tooltip: 'New project',
child: const Icon(Icons.add),
),
body: projectsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (projects) {
if (projects.isEmpty) {
return const Center(
child: Text(
'No projects yet.\nTap + to create one.',
textAlign: TextAlign.center,
),
);
}
final active =
projects.where((p) => p.status == 'active').toList();
final other =
projects.where((p) => p.status != 'active').toList();
return ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
if (active.isNotEmpty) ...[
_SectionHeader(title: 'Active (${active.length})'),
...active.map((p) => _ProjectExpansionTile(project: p)),
],
if (other.isNotEmpty) ...[
_SectionHeader(title: 'Other'),
...other.map((p) => _ProjectExpansionTile(project: p)),
],
],
);
},
),
);
}
void _showCreateDialog(BuildContext context, WidgetRef ref) {
showDialog<void>(
context: context,
builder: (dialogContext) => _CreateProjectDialog(
onCreate: (title, description, goal) async {
try {
await ref
.read(projectsProvider.notifier)
.create(title: title, description: description, goal: goal);
} on AppException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message)),
);
}
}
},
),
);
}
}
// ─── Section header ────────────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
title,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(color: Theme.of(context).colorScheme.primary),
),
);
}
}
// ─── Project expansion tile ────────────────────────────────────────────────────
class _ProjectExpansionTile extends ConsumerStatefulWidget {
final Project project;
const _ProjectExpansionTile({required this.project});
@override
ConsumerState<_ProjectExpansionTile> createState() =>
_ProjectExpansionTileState();
}
class _ProjectExpansionTileState
extends ConsumerState<_ProjectExpansionTile> {
bool _expanded = false;
Color _statusColor(BuildContext context) => switch (widget.project.status) {
'completed' => Colors.green,
'archived' => Colors.grey,
_ => Theme.of(context).colorScheme.primary,
};
void _showOptions(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.check_circle_outline),
title: const Text('Mark completed'),
onTap: () async {
Navigator.pop(context);
await ref.read(projectsProvider.notifier).updateProject(
widget.project.id,
{'status': 'completed'},
);
},
),
ListTile(
leading: const Icon(Icons.archive_outlined),
title: const Text('Archive'),
onTap: () async {
Navigator.pop(context);
await ref.read(projectsProvider.notifier).updateProject(
widget.project.id,
{'status': 'archived'},
);
},
),
ListTile(
leading: Icon(Icons.delete_outline,
color: Theme.of(context).colorScheme.error),
title: Text('Delete',
style: TextStyle(
color: Theme.of(context).colorScheme.error)),
onTap: () async {
Navigator.pop(context);
final confirm = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete project?'),
content: const Text(
'Notes and tasks will be unlinked, not deleted.'),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, true),
child: const Text('Delete'),
),
],
),
);
if (confirm == true && context.mounted) {
await ref
.read(projectsProvider.notifier)
.delete(widget.project.id);
}
},
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final statusColor = _statusColor(context);
final tasksAsync = ref.watch(tasksProvider);
final unfinished = tasksAsync.valueOrNull
?.where((t) =>
t.projectId == widget.project.id &&
t.status != TaskStatus.done)
.toList() ??
[];
final subtitle = unfinished.isEmpty
? (widget.project.description != null &&
widget.project.description!.isNotEmpty
? widget.project.description!
: null)
: '${unfinished.length} task${unfinished.length == 1 ? '' : 's'} in progress';
return ExpansionTile(
key: PageStorageKey('project-${widget.project.id}'),
initiallyExpanded: false,
onExpansionChanged: (v) => setState(() => _expanded = v),
leading: CircleAvatar(
backgroundColor: statusColor.withValues(alpha: 0.15),
child: Icon(Icons.folder_outlined, color: statusColor, size: 20),
),
title: GestureDetector(
onLongPress: () => _showOptions(context),
child: Text(widget.project.title),
),
subtitle: subtitle != null
? Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
)
: null,
trailing: _StatusChip(status: widget.project.status),
children: [
if (_expanded)
_ProjectTaskList(
project: widget.project,
unfinishedTasks: unfinished,
),
],
);
}
}
// ─── Expanded task list grouped by milestone ───────────────────────────────────
class _ProjectTaskList extends ConsumerWidget {
final Project project;
final List<Task> unfinishedTasks;
const _ProjectTaskList({
required this.project,
required this.unfinishedTasks,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final milestonesAsync =
ref.watch(projectMilestonesProvider(project.id));
return milestonesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Error loading milestones: $e',
style: TextStyle(
color: Theme.of(context).colorScheme.error)),
),
data: (milestones) {
// Only active milestones in order
final activeMilestones = milestones
.where((m) => m.status == 'active')
.toList()
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
if (unfinishedTasks.isEmpty) {
return _EmptyProjectContent(project: project);
}
// Build milestone → tasks map
final Map<int?, List<Task>> grouped = {};
for (final task in unfinishedTasks) {
grouped.putIfAbsent(task.milestoneId, () => []).add(task);
}
final widgets = <Widget>[];
// Milestone groups (in order)
for (final ms in activeMilestones) {
final tasks = grouped[ms.id];
if (tasks == null || tasks.isEmpty) continue;
widgets.add(_MilestoneHeader(milestone: ms));
for (final task in tasks) {
widgets.add(_TaskRow(task: task));
}
}
// No-milestone group
final noMilestoneTasks = grouped[null] ?? [];
if (noMilestoneTasks.isNotEmpty) {
if (activeMilestones.isNotEmpty) {
widgets.add(const _NoMilestoneHeader());
}
for (final task in noMilestoneTasks) {
widgets.add(_TaskRow(task: task));
}
}
widgets.add(_AddTaskRow(project: project));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widgets,
);
},
);
}
}
class _EmptyProjectContent extends StatelessWidget {
final Project project;
const _EmptyProjectContent({required this.project});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
'No open tasks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
),
_AddTaskRow(project: project),
],
);
}
}
// ─── Milestone header ──────────────────────────────────────────────────────────
class _MilestoneHeader extends StatelessWidget {
final Milestone milestone;
const _MilestoneHeader({required this.milestone});
@override
Widget build(BuildContext context) {
final pct = milestone.total == 0 ? 0.0 : milestone.pct / 100.0;
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.flag_outlined, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
milestone.title,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.secondary,
fontWeight: FontWeight.w600,
),
),
),
Text(
'${milestone.completed}/${milestone.total}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: pct,
minHeight: 3,
borderRadius: BorderRadius.circular(2),
backgroundColor:
colorScheme.secondaryContainer.withValues(alpha: 0.4),
valueColor:
AlwaysStoppedAnimation<Color>(colorScheme.secondary),
),
],
),
);
}
}
class _NoMilestoneHeader extends StatelessWidget {
const _NoMilestoneHeader();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(56, 12, 16, 2),
child: Text(
'No milestone',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
);
}
}
// ─── Task row ──────────────────────────────────────────────────────────────────
class _TaskRow extends StatelessWidget {
final Task task;
const _TaskRow({required this.task});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isInProgress = task.status == TaskStatus.inProgress;
final dotColor = isInProgress ? colorScheme.primary : colorScheme.outline;
final priorityColor = switch (task.priority) {
TaskPriority.high => Colors.red,
TaskPriority.medium => Colors.orange,
TaskPriority.low => Colors.blue,
_ => null,
};
return ListTile(
dense: true,
contentPadding: const EdgeInsets.fromLTRB(56, 0, 16, 0),
leading: Icon(
isInProgress ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 18,
color: dotColor,
),
title: Text(task.title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: task.dueDate != null
? Text(
_formatDue(task.dueDate!),
style: TextStyle(
fontSize: 11,
color: _isDueOverdue(task.dueDate!)
? colorScheme.error
: colorScheme.outline,
),
)
: null,
trailing: priorityColor != null
? Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: priorityColor,
shape: BoxShape.circle,
),
)
: null,
onTap: () => context.push(
Routes.taskEdit.replaceFirst(':id', '${task.id}'),
),
);
}
String _formatDue(DateTime due) {
final now = DateTime.now();
final diff = due.difference(DateTime(now.year, now.month, now.day)).inDays;
if (diff == 0) return 'Due today';
if (diff == 1) return 'Due tomorrow';
if (diff < 0) return 'Overdue ${(-diff)} day${(-diff) == 1 ? '' : 's'}';
if (diff < 7) return 'Due in $diff days';
return 'Due ${due.month}/${due.day}';
}
bool _isDueOverdue(DateTime due) {
final now = DateTime.now();
return due.isBefore(DateTime(now.year, now.month, now.day));
}
}
// ─── Add task row ──────────────────────────────────────────────────────────────
class _AddTaskRow extends StatelessWidget {
final Project project;
const _AddTaskRow({required this.project});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(48, 4, 16, 8),
child: TextButton.icon(
onPressed: () => context.push('${Routes.taskNew}?projectId=${project.id}'),
icon: const Icon(Icons.add, size: 16),
label: const Text('New task'),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.outline,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
textStyle: const TextStyle(fontSize: 13),
),
),
);
}
}
// ─── Status chip ───────────────────────────────────────────────────────────────
class _StatusChip extends StatelessWidget {
final String status;
const _StatusChip({required this.status});
@override
Widget build(BuildContext context) {
final (label, color) = switch (status) {
'completed' => ('Done', Colors.green),
'archived' => ('Archived', Colors.grey),
_ => ('Active', Theme.of(context).colorScheme.primary),
};
return Chip(
label: Text(label, style: const TextStyle(fontSize: 11)),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
side: BorderSide(color: color.withValues(alpha: 0.4)),
backgroundColor: color.withValues(alpha: 0.1),
labelStyle: TextStyle(color: color),
);
}
}
// ─── Create project dialog ─────────────────────────────────────────────────────
class _CreateProjectDialog extends StatefulWidget {
final Future<void> Function(String title, String? description, String? goal)
onCreate;
const _CreateProjectDialog({required this.onCreate});
@override
State<_CreateProjectDialog> createState() => _CreateProjectDialogState();
}
class _CreateProjectDialogState extends State<_CreateProjectDialog> {
final _titleController = TextEditingController();
final _descController = TextEditingController();
final _goalController = TextEditingController();
bool _saving = false;
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
_goalController.dispose();
super.dispose();
}
Future<void> _submit() async {
final title = _titleController.text.trim();
if (title.isEmpty) return;
setState(() => _saving = true);
try {
await widget.onCreate(
title,
_descController.text.trim().isEmpty ? null : _descController.text.trim(),
_goalController.text.trim().isEmpty ? null : _goalController.text.trim(),
);
if (mounted) Navigator.pop(context);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('New Project'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
autofocus: true,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 2,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _goalController,
decoration: const InputDecoration(
labelText: 'Goal (optional)',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _submit,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create'),
),
],
);
}
}
@@ -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'),
),
],
),
),
);
}
}
+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);
}
+2 -1
View File
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/exceptions.dart';
import '../../data/api/tasks_api.dart';
import '../../data/models/task.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/tasks_provider.dart';
@@ -239,6 +238,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<TaskStatus>(
// ignore: deprecated_member_use
value: _status,
decoration: const InputDecoration(
labelText: 'Status',
@@ -253,6 +253,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<TaskPriority>(
// ignore: deprecated_member_use
value: _priority,
decoration: const InputDecoration(
labelText: 'Priority',
-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,
),
),
),
),
),
);
}
}
+272
View File
@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/constants.dart';
import '../data/models/note.dart';
import '../data/models/project.dart';
import '../data/models/task.dart';
import '../providers/tasks_provider.dart';
// ── Note card ────────────────────────────────────────────────────────────────
class NoteLibraryCard extends StatelessWidget {
final Note note;
const NoteLibraryCard({super.key, required this.note});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => context
.push(Routes.noteDetail.replaceFirst(':id', '${note.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.article_outlined,
size: 15, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(
note.title.isNotEmpty ? note.title : 'Untitled',
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
_relativeTime(note.updatedAt),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
],
),
if (note.body.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
note.body.replaceAll('\n', ' '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (note.tags.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 2,
children: note.tags
.take(4)
.map((t) => Chip(
label: Text(t),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
))
.toList(),
),
],
],
),
),
),
);
}
}
// ── Task card ────────────────────────────────────────────────────────────────
class TaskLibraryCard extends ConsumerWidget {
final Task task;
const TaskLibraryCard({super.key, required this.task});
Color _priorityColor(BuildContext context) {
return switch (task.priority) {
TaskPriority.high => const Color(0xFFEF4444),
TaskPriority.medium => const Color(0xFFF59E0B),
_ => Theme.of(context).colorScheme.onSurfaceVariant,
};
}
IconData get _statusIcon => switch (task.status) {
TaskStatus.done => Icons.check_circle,
TaskStatus.inProgress => Icons.timelapse,
_ => Icons.radio_button_unchecked,
};
Color _statusColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (task.status) {
TaskStatus.done => const Color(0xFF22C55E),
TaskStatus.inProgress => cs.primary,
_ => cs.onSurfaceVariant,
};
}
TaskStatus get _nextStatus => switch (task.status) {
TaskStatus.todo => TaskStatus.inProgress,
TaskStatus.inProgress => TaskStatus.done,
_ => TaskStatus.todo,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 14, 10),
child: Row(
children: [
// Status cycle button
IconButton(
icon: Icon(_statusIcon, color: _statusColor(context)),
onPressed: () => ref
.read(tasksProvider.notifier)
.updateTask(task.id, {'status': _nextStatus.value}),
tooltip: 'Cycle status',
visualDensity: VisualDensity.compact,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: task.status == TaskStatus.done
? TextDecoration.lineThrough
: null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (task.dueDate != null) ...[
const SizedBox(height: 2),
Text(
'Due ${_formatDate(task.dueDate!)}',
style: theme.textTheme.labelSmall?.copyWith(
color: task.dueDate!.isBefore(DateTime.now()) &&
task.status != TaskStatus.done
? const Color(0xFFEF4444)
: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (task.priority != TaskPriority.none &&
task.priority != TaskPriority.low)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _priorityColor(context),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
}
// ── Project card ─────────────────────────────────────────────────────────────
class ProjectLibraryCard extends StatelessWidget {
final Project project;
const ProjectLibraryCard({super.key, required this.project});
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = _parseColor(project.color);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context
.push('/projects/${project.id}/tasks'),
borderRadius: BorderRadius.circular(14),
child: Row(
children: [
// Colour strip
Container(width: 6, height: 64, color: color),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.title,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (project.description?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
project.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
const SizedBox(width: 12),
],
),
),
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'just now';
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
if (diff.inDays < 1) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return _formatDate(dt);
}
String _formatDate(DateTime dt) {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
return '${months[dt.month - 1]} ${dt.day}';
}
+2 -1
View File
@@ -23,12 +23,13 @@ class ProjectSelector extends ConsumerWidget {
return projectsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (projects) {
final active =
projects.where((p) => p.status == 'active').toList();
return DropdownButtonFormField<int?>(
// ignore: deprecated_member_use
value: value,
decoration: decoration ??
const InputDecoration(
+214 -22
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:
@@ -250,10 +290,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "3.3.1"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -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:
+5 -4
View File
@@ -2,7 +2,7 @@ name: fabled_app
description: "FabledAssistant mobile client for Android."
publish_to: 'none'
version: 26.03.02+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
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: