Compare commits

...

112 Commits

Author SHA1 Message Date
bvandeusen c3a650e3f5 Release v26.04.29.2 — Drop broken WeatherCard from journal 2026-04-29 16:11:00 +00:00
bvandeusen a77b71e0e2 fix(journal): drop broken WeatherCard from journal screen
The WeatherCard widget expected a flat shape (`current_temp`,
`condition`, `today_high`, ...) but the prep payload sends raw
OpenMeteo data nested under `forecast_json` per location. Every
field read as null, so the card pinned to the top of the journal
chat showed "null°" and "null/null" — visual noise covering up
the prep prose.

The prep prose itself already mentions weather plainly ("Weather at
home will reach a high of 15.9° with a 0% chance of precipitation"),
so the visual card is redundant. Removing it eliminates the bug
without losing any actual info; the weather data is still in
metadata.sections.weather if a future reader needs it.

Also deletes the unused widget file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 10:54:22 -04:00
bvandeusen 116dc42923 Release v26.04.29.1 — Tier 2 offline & Illuminated Transcript 2026-04-29 02:30:20 +00:00
bvandeusen 76aff4ea9e feat(offline): tier 2 phase 4 — per-row pending-sync indicators
A small cloud-upload glyph appears next to any row whose id has a
queued offline write (offline-created temp ids and pending edits both
count). Tooltip reads "Pending sync — will save when online". Renders
nothing during normal online operation so the list stays clean.

- DB: `watchPendingIds(domain)` streams the union of target_id and
  temp_id across the queue, scoped per domain.
- Per-domain Riverpod stream providers for notes / tasks / projects.
- New `PendingSyncBadge` widget — used by KnowledgeItemCard (both
  list and grid variants), `_ProjectCard`, and `_TaskRow` in the
  project workspace.

flutter analyze clean; 21 tests pass. Closes #147 — all four phases
of Tier 2 offline mode are in place.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:14:36 -04:00
bvandeusen 7df7b5ff85 feat(offline): tier 2 phase 3 — write queue with optimistic UI
Offline writes now queue + apply optimistically instead of throwing.
On reconnect, the queue drains automatically; conflicts and rejections
surface as one-time SnackBars so the user knows their edit didn't land.

- Drift schema v3: `pending_writes` table (verb + payload + baseline +
  tries + last_error). Negative ids serve as cache placeholders for
  offline-created rows until replay assigns the server id.
- Each write repository (notes, tasks, projects, milestones, events)
  now catches NetworkException, applies the change to cache (with a
  fresh `nextTempId()` for creates), and enqueues the API call.
- Edits to a still-queued offline-created row coalesce into the
  original create payload — only one server call per row, in order.
- Deletes of still-queued offline-created rows drop the queue entry
  and the cache row; no server call ever happens.
- New WriteQueue service drains the queue oldest-first.
  Server-wins on `updated_at` baseline check (notes/tasks/projects);
  last-writer-wins for milestones/events (no getOne available).
  4xx → drop + surface as `rejected`; conflict → drop + `overwritten`;
  404 on update → drop + `missing`; 5xx/network → keep + retry next
  online cycle.
- Replay fires on AuthStatus → authenticated transitions
  (cold-start, came-back-online, login).
- OfflineBanner shows "Retry (N)" with the pending-writes count.
- Distinct ServerException added so 4xx no longer masquerade as
  NetworkException — the queue can drop them instead of looping.

flutter analyze clean; 21 tests pass (3 new for QueueFailure messaging).
Phase 4 (read-only UI indicators on cached/temp rows) still ahead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:08:40 -04:00
bvandeusen fe10067761 feat(offline): tier 2 phase 2 — extend cache to tasks/projects/milestones/events/conversations
Phase 1 cached only notes. Phase 2 brings the read-through pattern to every
domain the app reads in bulk:

- Drift schema v2: 5 new cached_* tables + onUpgrade migration. Calendar
  events use range-scoped read/write (replaceEventsInRange) so disjoint
  month fetches don't clobber each other; milestones are per-project.
- Wrap TasksRepository, ProjectsRepository, MilestonesRepository, and
  ChatRepository (conversation list only) with NetworkException fallback.
  Writes hit the API then sync the cache.
- New EventsRepository wrapping EventsApi; calendar_provider and
  event_form_sheet repointed at the repository.
- OfflineBanner now surfaces getLatestSync() — most-recent timestamp
  across all domains — so the hint reads sensibly regardless of screen.

flutter analyze clean; existing tests pass. Phase 3 (offline write queue)
and Phase 4 (read-only UI indicators) still to come.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:22:06 -04:00
bvandeusen 6ef658558a feat(offline): tier 2 phase 1 — Drift cache + read-through for notes
First slice of the tier 2 offline mode work tracked in Fable #147.
Notes are the only domain wired so far; this PR establishes the
shape and proves the round-trip end-to-end before extending to tasks
/ projects / events / etc. in phase 2.

Local store
- Adds drift ^2.20.0 + sqlite3_flutter_libs ^0.5.24 + path ^1.9.0
  runtime deps and drift_dev / build_runner dev deps.
- New lib/data/local/database.dart: FabledDatabase with CachedNotes
  (mirroring the Note model 1:1; tags stored as JSON-encoded text) and
  SyncMetadata (per-domain last_synced_at). Database file lives at
  $appDocs/fabled_cache.sqlite; opened lazily in a background isolate.
- fabledDatabaseProvider on the existing api_client_provider; closes
  the DB when the ProviderScope disposes.

Repository pattern
- NotesRepository now takes (NotesApi, FabledDatabase). Reads attempt
  the network first; on success the response is written to the cache.
  On NetworkException reads fall back to the cache (rethrowing if it
  is empty so fresh-install offline still surfaces the network error).
- Writes (create/update/delete) hit the server then sync the cache;
  offline write queueing is phase 3 and explicitly not wired here.
- AuthStatus.offline stays owned by AuthNotifier.verify()'s heartbeat;
  this repo deliberately doesn't poke that state.

OfflineBanner
- Reads notesRepository.lastSyncedAt(); when present, swaps
  "Offline — showing cached data." for "Offline — last sync X min
  ago." A 30s timer refreshes the relative-time string while the
  banner is mounted.
- Falls back to the generic message if no cache exists yet (fresh
  install offline).

Verification
- flutter analyze: No issues found

Out of scope (later phases per the task body)
- Tasks / projects / milestones / events / conversation list / journal
  day caching (phase 2 — same wrapper pattern, repeated)
- Generic write queue with conflict resolution on updated_at (phase 3)
- Read-only UI indicators on screens that depend on cached data
  (phase 4)
- Full offline chat (out of scope; needs a local model)
- RAG / search over cached content (out of scope)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:08:06 -04:00
bvandeusen b9e68e3bc8 feat(design): surface phase — Lucide icons, input radius, Illuminated Transcript, ActionColors
Per-screen application of the design system to the Flutter app.
Mirrors the web's surface phase landed in FabledScribe v26.04.28.1.
Foundation port shipped in 0f05f47; this is the surface work.

Lucide icon migration
- Added lucide_icons ^0.257.0 dependency
- 107 Material Icons references → LucideIcons across 21 files. Drop-in
  IconData swap (Icon(LucideIcons.X) instead of Icon(Icons.x)).
- Lucide import added to each touched file.

Input border radius
- theme.dart inputDecorationTheme borderRadius 24 → 8 in both light
  and dark themes. Doc says radius-md (8px) for inputs; previous pill
  shape was Material default that the doc deviates from.

Illuminated Transcript pattern (ChatMessageBubble)
- User bubble: accent-tinted border → neutral Pewter (scheme.outline).
  Asymmetric corner already correct (bottomRight 4px).
- Assistant bubble: topLeft corner 4 → 16; only bottomLeft stays 4
  (the "tail" effect, mirroring web's `border-bottom-left-radius: 4px`).
  Background switched from surfaceContainerHighest (Slate) to surface
  (Iron) per the doc spec "card surface".
- Assistant bubble glow shadow added — accent-tinted blur (28px alpha
  0.14) + depth shadow (8px alpha 0.4 black). Mirrors web's
  --color-bubble-asst-shadow.

ActionColors wiring (Hybrid rule)
- 5 'Delete' confirm buttons across notes / tasks / chat conversations
  / calendar event sheet → Oxblood action-destructive via the
  ActionColors ThemeExtension defined in the foundation port. Foreground
  for ghost/text variants, backgroundColor for filled.
- Calendar event Save button → Moss action-primary. The first call
  site to wire ActionColors.primary; serves as the pattern for future
  Save reclassifications.
- Other Save buttons (note edit, task edit, project edit, etc.) still
  flow through colorScheme.primary (dusty violet) and read as
  brand-moment. Reclassifying those is deferred — the wiring pattern
  is established and can be applied incrementally as files are touched.

Indigo cleanup
- 4 hardcoded #7C3AED / #5B21B6 literals → dusty-violet equivalents
  (#5B4A8A / #3F3560). Spots: project_tasks_screen color fallback
  (×2), journal_screen gradient.

Verification
- flutter analyze: No issues found

What's deferred
- Per-screen Save / Cancel reclassification beyond the calendar event
  Save button. Wiring pattern established; rollout opportunistic.
- Long-form 1.7 line-height on assistant Markdown content (would
  require MarkdownStyleSheet work; minor).
- Surface walk on Knowledge / Projects / Settings screens for any
  hardcoded styling that needs touch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:14:28 -04:00
bvandeusen 1d9e4af6f3 Merge pull request 'Release v26.04.28.1 — Design system foundation port' (#30) from dev into main 2026-04-28 13:12:57 +00:00
bvandeusen 0f05f47eef feat(design): foundation port — dusty violet, warm parchment, action tokens
Mirrors the web frontend's design-system foundation pass (shipped on web
2026-04-27 in 7a9a8b7) for the Flutter app. Single-file change to
lib/core/theme.dart — most widgets read colorScheme.primary so the
palette flip alone covers a large surface.

Palette swap
- dark: cool grey #0F0F14 / #16161F → Obsidian #14171A / Iron #1E2228
  with Slate #2C313A and Pewter #3F4651 borders; Parchment #E8E4D8
  primary text, Vellum #C2BFB4 secondary
- light: white-and-cool-blue → warm parchment #F5F1E8 page / #FBF8F0
  cards / #EFEAE0 inset; deep ink #14171A text; warm pewter #D9D6CE
  borders
- primary (both modes): indigo #7C3AED → dusty violet #5B4A8A
- error: indigo-red pair → terracotta #C04A1F (semantic Error per the
  doc; distinct from destructive Oxblood used for delete buttons)

Action token ThemeExtension
- New ActionColors extension exposes Moss / Bronze / Oxblood / Pewter
  outside the ColorScheme so widgets can read them via
  Theme.of(context).extension<ActionColors>()!.primary etc.
- Hybrid rule: ColorScheme.primary stays the dusty-violet brand accent
  (Send buttons, empty-state CTAs); action buttons (Save / Cancel /
  Delete / ghost) use the ActionColors palette. No widget refactors in
  this commit — extension is defined for surface-phase work to consume.

Typography
- Inter loaded for body / labels / titleMedium-and-below (was system
  default)
- Fraunces still loaded for display / headline / titleLarge — only at
  ≥18px per the doc rule
- JetBrains Mono available for code at call sites via
  GoogleFonts.jetBrainsMono() (Flutter has no mono TextTheme slot)

GradientButton
- Brand-moment CTA equivalent of the web's --gradient-cta. Colors
  swapped from indigo gradient to dusty-violet (#5B4A8A → #3F3560);
  glow shadow rgba updated to match.

Out of scope (deferred to later surface-phase work)
- Per-screen button reclassification (Save → Moss FilledButton, Delete
  → Oxblood, etc.) — the ActionColors extension exists, no widgets
  reach for it yet
- Lucide icon migration — separate task, requires picking a
  flutter_lucide / lucide_icons package
- Input border radius (currently 24 / pill, doc says 8) — leave for
  surface phase
- Voice/tone copy touchups
- Chat bubble Illuminated Transcript pattern (assistant left-edge
  accent + glow) — not yet ported to ChatMessageBubble

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:30:55 -04:00
bvandeusen 5bdd4f565b Merge pull request 'Release v26.04.27 — Briefing → Journal; news/RSS removed' (#29) from dev into main 2026-04-27 12:19:58 +00:00
bvandeusen dd250788f6 feat(journal): replace briefing surface with journal; remove news/RSS
The backend retired /api/briefing/* and the RSS feature entirely. This
Flutter change mirrors what landed web-side: rename the briefing surface
to journal, repoint at /api/journal/*, and drop the news/RSS UI since
its endpoints no longer exist.

New (mirrors briefing structure with adapted shapes):
- lib/data/api/journal_api.dart — getToday, getDay, getDays, triggerPrep
- lib/data/models/journal_day.dart — {day_date, conversation, messages}
- lib/providers/journal_provider.dart — async notifier, sendReply, polling,
  silent refresh, regeneratePrep. Mirrors the briefing notifier 1:1
- lib/widgets/journal_prep_card.dart — adapted briefing_digest_card
- lib/screens/journal/journal_screen.dart — adapted briefing_screen,
  weather card preserved (rendered from msg_metadata.sections.weather
  on the daily-prep assistant message). News cards / RSS reactions /
  article-discuss removed
- lib/screens/journal/journal_history_screen.dart — past days picker
  pulls /api/journal/days, drills into /api/journal/day/<iso>

Wiring:
- Routes.briefing → Routes.journal (constants.dart)
- Routes.news removed
- briefingApiProvider → journalApiProvider (api_client_provider.dart)
- newsApiProvider removed
- app.dart: shell tab "Briefing" → "Journal"; News destination removed
  from nav rail, bottom nav, and the More sheet
- splash_screen.dart and login_screen.dart: redirect Routes.journal
  instead of Routes.briefing
- chat_api.dart: drop openArticleInChat (calls deleted /api/chat/from-article)
- settings_provider.dart: drop rssEnabled getter and rssEnabledProvider

Deleted:
- lib/screens/briefing/ (whole directory)
- lib/screens/news/ (whole directory)
- lib/data/api/briefing_api.dart, news_api.dart
- lib/data/models/briefing_conversation.dart, briefing_feed.dart, news_item.dart
- lib/providers/briefing_provider.dart, news_provider.dart
- lib/widgets/briefing_digest_card.dart, news_card.dart
- test cases for NewsItem and BriefingFeed in test/widget_test.dart

flutter analyze: 0 issues.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 07:58:09 -04:00
bvandeusen 5e48a4fb69 Merge pull request 'Release v26.04.18.1 — Offline-aware launch' (#28) from dev into main 2026-04-18 16:58:54 +00:00
bvandeusen 01aa362d3c feat(offline): detect server offline and keep returning users signed in
Previously, if the backend was unreachable at launch, the splash screen
routed to the login screen. The server URL remained persisted in
SharedPreferences but visually appeared lost, frustrating any user who
isn't the service operator.

- New AuthStatus.offline distinguishes network failures (NetworkException)
  from HTTP 401 in AuthNotifier.verify().
- Persist has_ever_logged_in flag on first successful verify/login.
- Offline + ever-logged-in lands on the briefing with a sticky offline
  banner (retry button) instead of being punted to login.
- OfflineBanner widget is Tier-2-ready so we can surface "last sync X min
  ago" once real caching lands (Fable task #147).
2026-04-18 12:58:18 -04:00
bvandeusen 3c9602c7c9 Merge pull request 'feat: voice fixes, background updates, rss_enabled gating, chat images' (#27) from dev into main 2026-04-18 03:36:14 +00:00
bvandeusen aba0ca6256 feat: gate News tab and briefing news cards on server rss_enabled setting
Fetch server-side settings on app launch via new serverSettingsProvider.
Hide News from bottom sheet and NavigationRail when RSS is disabled.
Skip news card rendering in briefing messages when disabled.
Refactor tab navigation to use dynamic tabs list and route-based refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 14:29:05 -04:00
bvandeusen 70a3279192 feat: background update download, snackbar prompt, chat image rendering
Update provider: auto-downloads APK in background after finding a newer
version, prompts via snackbar only when ready, cleans up old APKs on
startup. Replaces modal dialog with dismissible snackbar.

Chat bubble: resolve relative image URLs (/api/images/{id}) against
server base URL with auth cookies so search_images results render on
the phone app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:38:48 -04:00
bvandeusen fc6c9648f9 fix(voice): eliminate dual-recorder conflict and defunct element crashes
VadHandler is now the sole mic owner — removed separate AudioRecorder that
caused audio focus contention on Android. Audio from onSpeechEnd is encoded
as WAV for Whisper. Provider switched to autoDispose to match widget lifecycle,
preventing defunct element assertions. Recording UI deferred until mic is open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:04:03 -04:00
bvandeusen 413b82f724 Merge pull request 'Release v26.04.16.1 — Silero VAD voice detection' (#26) from dev into main 2026-04-17 01:00:54 +00:00
bvandeusen 5f11b344a3 feat(voice): replace amplitude silence detection with Silero VAD
Use VadHandler from the vad package (Silero VAD v5 ONNX) for speech
detection instead of amplitude thresholds. VAD runs its own PCM
stream while the file recorder captures AAC-LC for Whisper. Grace
period starts on speech-start, auto-stop on speech-end after grace.
No-speech guard shows error on manual stop without detected speech.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:57:52 -04:00
bvandeusen 8959b62abe chore: upgrade record to v6, add vad package
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:55:18 -04:00
bvandeusen c967a49e5a Release v26.04.15.1 — Tablet landscape improvements 2026-04-16 05:52:49 +00:00
bvandeusen 58d4cfab4d feat: tablet landscape improvements
Shell: move Projects/News/Calendar into ShellRoute so the NavigationRail
persists across all screens. Show all 6 nav destinations on tablet
instead of 3+More overflow. Phone bottom nav unchanged.

Chat: master-detail layout on tablet — conversations list (320px) with
inline chat panel. Tapping a conversation updates state instead of
pushing a new route.

Knowledge: responsive grid (2 cols portrait, 3 cols landscape) with
card layout showing icon, title, body preview, and tags. Fix snippet
data — read json['snippet'] which the API actually sends. Bump snippet
length from 120 to 200 chars. Tasks now show description as snippet.

News: responsive grid with the same breakpoints. Larger snippet
(5 lines) in grid mode via snippetMaxLines parameter.

Briefing: centered reading column (maxWidth 700) on wide screens,
matching the web UI layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 01:31:52 -04:00
bvandeusen ee0f354312 Merge pull request 'Release v26.04.15.1 — Dynamic voice silence threshold' (#24) from dev into main 2026-04-15 04:38:25 +00:00
bvandeusen bdaa5210f0 feat(voice): dynamic silence threshold
The previous -40 dBFS static threshold sat right on top of typical
phone mic ambient, so silence detection rarely fired and the user
always had to tap stop manually.

Silence threshold is now dynamic: track the session peak dBFS and
treat "silent" as 15 dB below peak. Auto-calibrates per mic and
environment rather than assuming a fixed ambient level.

- Grace period (1500 ms) at start so the user has time to begin
  speaking before checks arm.
- Static -35 dB fallback until the peak clears -20 dB so a dead-
  silent session doesn't spin forever.
- Silence duration bumped 1500 → 2000 ms for breathing room.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:28:04 -04:00
bvandeusen ad20c9f9d4 Merge pull request 'Release v26.04.14.3 — Live amplitude mic pulse' (#23) from dev into main 2026-04-15 02:54:47 +00:00
bvandeusen 48c134ce6a feat(voice): pulse mic button with live amplitude
VoiceState now carries a normalized mic amplitude (0..1) updated
from the existing onAmplitudeChanged subscription, with a 0.02
change threshold so we don't spam rebuilds.

VoiceMicButton swaps the constant-rate AnimationController for an
AnimatedScale + animated glow driven by the live amplitude. 0.1
floor keeps the button breathing on silence; 120ms ease-out smooths
between 200ms samples.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:46:48 -04:00
bvandeusen 634b6d05cf Merge pull request 'Release v26.04.14.2 — News card title readability' (#22) from dev into main 2026-04-15 01:45:43 +00:00
bvandeusen 00878a8a42 fix(news): use onSurface for article titles instead of primary
Purple-on-black was eye-catching but hard to read. Switch title text
to onSurface for contrast; keep the underline in primary at 70% alpha
as the "this is a link" signal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:13:31 -04:00
bvandeusen ddbf867b03 Merge pull request 'Release v26.04.14.1' (#21) from dev into main 2026-04-14 12:00:30 +00:00
bvandeusen 51f1cffe79 ci: drop main-branch trigger so merge commits don't re-run dev CI
Mirrors the same fix applied to the fabledassistant repo: merges to
main only happen after dev already passed CI and tagged releases are
the sole trigger for a signed APK build, so re-running analyze+test on
the merge commit just burns runner time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:55:55 -04:00
bvandeusen fa84e40efc feat(briefing): stall watchdog + pull-to-refresh; chat attaches to in-flight streams
Briefing chat now uses the same StreamIterator + 45s per-event timeout
as the main chat provider, so a dropped SSE socket no longer leaves
isBriefingStreaming stuck true. Adds refreshMessages() that unfreezes
state when the server-side message is complete, wired to a new
pull-to-refresh gesture and the app lifecycle-resume hook.

Chat provider gains attachToGeneration() — safe to call unconditionally
on screen init, so landing on a conversation that's already mid-stream
(e.g. the /news discuss button, which now creates a conv and auto-kicks
generation server-side) picks up live tokens instead of freezing on the
placeholder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:55:47 -04:00
bvandeusen 1c4e3c018b Merge pull request 'Release v26.04.13.2' (#20) from dev into main 2026-04-13 23:00:36 +00:00
bvandeusen 75b7d6d0fe feat(chat): stall watchdog, pull-to-refresh, and unfreeze on refresh
Mobile chat occasionally froze with the input disabled and nothing
progressing — the underlying cause is SSE sockets dropping silently on
mobile network handoffs / proxy idle timeouts. Dio never observes the
close, so the send loop's `await for` hangs indefinitely with
isStreaming=true.

Three changes:
- Wrap the SSE stream in a StreamIterator with a 45s per-event timeout
  as a stall watchdog. On timeout, break out and let the polling pass
  reconcile state from the server.
- Add pull-to-refresh on the chat message list and an AppBar refresh
  button with inline spinner feedback. Works on both empty and
  populated states.
- MessagesNotifier.refresh() now also clears isStreaming /
  streamingStatus when the server reports the latest assistant message
  as complete — so a stuck UI unfreezes the moment the user pulls down
  or taps refresh, even if the original SSE loop is still hanging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:13:46 -04:00
bvandeusen 8ea244ecaa feat(chat): tap tool-call chip to open the created note/task/event
Chips now show the entity title ("Created note: Grocery List") and
deep-link to the matching screen when tapped — notes to the detail
screen, tasks to the edit screen, projects to the project tasks view,
events to the calendar. Read-only tools and errors stay non-tappable.

Closes the main goal behind the mobile chip work: seeing that a note
was created and jumping straight to it without backing out to the
library.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:06:03 -04:00
bvandeusen a3fe0b4b61 feat(chat): render tool-call chips and live peek status in mobile bubbles
The mobile app was receiving SSE status events but showing the peek text
only while the assistant bubble was empty — as soon as the first token
arrived, the status line was replaced by streaming content and any tool
calls fired mid-turn left no trace. Tool call SSE events were also being
dropped by the parser, and Message.fromJson never read the persisted
tool_calls array, so chips never rendered after reload either.

Parse tool_call SSE frames into a new ChatToolCall event, carry tool
calls on Message, and update the chat and briefing streaming loops to
append chips to the in-flight assistant message as they arrive. Rework
ChatMessageBubble to show a chip row + rolling peek status line above
any streamed text, matching the web ToolCallCard/status indicator
behaviour across chat, briefing, and briefing history surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:48:16 -04:00
bvandeusen 6771ec5e81 Merge pull request 'Release v26.04.13.1' (#19) from dev into main 2026-04-13 05:13:44 +00:00
bvandeusen 4240c90d55 fix(voice,knowledge): mic recording + pagination + STT-only mode
Voice: recreate AudioRecorder each session to avoid stale native
state, improve permission handling (re-check after settings), show
feedback after 3 consecutive empty transcripts, allow STT-only mode
when TTS is unavailable.

Knowledge: hydrate IDs immediately after loading more pages so
scroll-based pagination doesn't stall at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:34:00 -04:00
bvandeusen 36644cf8a5 ci: switch to ci-runner base image
Update runs-on from py3.12-node22 to ci-runner to match the new
shared runner base image with uv, ruff, jq, and tzdata baked in.
2026-04-12 00:00:40 -04:00
bvandeusen cb5ce44bbe ci: gate on dev/main pushes, pin Flutter, extract release script
- trigger: add branches: [dev, main] so analyze+test run on every
  push, not just release tags. Build job stays tag-gated via
  if: startsWith(github.ref, 'refs/tags/')
- pin Flutter to 3.41.6 instead of floating :stable for reproducible
  release builds
- concurrency: cancel in-progress non-tag runs
- permissions: contents: read default, contents: write scoped to build
- extract release publish logic to scripts/publish_apk_release.sh so
  it's testable locally (bash -x with env vars) and the YAML stays
  readable. Adds set -euo pipefail + curl -f so failures surface
  instead of getting swallowed by || echo "skipped"
- drop the broken artifact upload step (silently swallowed errors)
2026-04-11 16:11:50 -04:00
bvandeusen 356709856f Merge pull request 'fix(test): CalendarEvent test expects local time after toLocal() change' (#18) from dev into main 2026-04-08 21:22:44 +00:00
bvandeusen 6e067f99ef fix(test): update CalendarEvent test to expect local time after toLocal() change 2026-04-08 17:16:23 -04:00
bvandeusen ab3a482705 Merge pull request 'Violet theme, flicker-free refresh, STT context' (#17) from dev into main 2026-04-08 18:52:27 +00:00
bvandeusen 47c190891e fix(knowledge): hold stale items during refresh to eliminate flicker 2026-04-08 14:45:57 -04:00
bvandeusen 3e888b6458 fix: eliminate pull-to-refresh flicker by holding stale data during re-fetch 2026-04-08 14:39:50 -04:00
bvandeusen 6c29b685e8 feat(theme): shift Android palette from indigo to deep violet to match web identity 2026-04-08 13:54:39 -04:00
bvandeusen 5957551546 Merge pull request 'STT context, refresh improvements, calendar timezone fix' (#16) from dev into main 2026-04-07 21:52:20 +00:00
bvandeusen d2582f9111 feat: reduce resume cooldown to 30s; add pull-to-refresh to News, Calendar; refresh chat on resume 2026-04-07 17:43:46 -04:00
bvandeusen 36350d35b1 feat(stt): pass last assistant response as Whisper context to reduce mishearings 2026-04-07 09:57:07 -04:00
bvandeusen 96e6b6466f Merge pull request 'fix(calendar): convert event times to local timezone on parse' (#15) from dev into main 2026-04-07 03:07:22 +00:00
bvandeusen d75d34ce8e fix(calendar): convert event times to local timezone on parse 2026-04-06 23:05:33 -04:00
bvandeusen 1c97f9dea5 Android app: Calendar, News, Knowledge, Voice, Nav restructure, bug fixes 2026-04-07 02:41:10 +00:00
bvandeusen c177bf0691 fix: restore offline queue for captures, drain via chat on retry
Offline queue (SharedPreferences) persists captures when the device
is offline and replays them as chat conversations on next launch,
preserving the same fire-and-forget guarantee as the online path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:06:13 -04:00
bvandeusen 4ebc57d2e5 feat: quick capture sends to chat instead of quick-capture endpoint
Replace POST /api/quick-capture with: create a conversation, send the
message, and fire-and-forget the SSE generation stream so the assistant
processes the request in the background without blocking the UI.
The new conversation appears immediately in the chat tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:00:33 -04:00
bvandeusen 946b70ecc4 fix: use dialog context in event delete confirmation
Same navigator mismatch as the chat delete bug — outer context inside
showDialog builder resolves to the wrong navigator ancestor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:35:41 -04:00
bvandeusen 6ea268bf58 fix: use dialog context in delete confirmation to prevent shell nav pop
Navigator.pop(outerContext) inside a showDialog builder resolves to the
ShellRoute's nested navigator instead of the root navigator where the
dialog lives, popping the conversations route and showing a black screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 18:33:04 -04:00
bvandeusen 7e332530fb feat: Calendar, News, Knowledge fixes, Voice STT fix, auto-refresh, chat improvements 2026-04-06 21:52:51 +00:00
bvandeusen 79dce1a01c feat: wire discuss button in briefing RSS cards, cap cards at 3
Adds discussArticle() to BriefingApi and wires it through to the
RSS news cards in BriefingScreen so tapping Discuss opens a chat
conversation seeded with the article. Also caps RSS cards per
message at 3 to avoid overly long briefing threads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:49:16 -04:00
bvandeusen cb3a09756f fix: create conversations with empty title so server auto-names them
The app was creating conversations with title 'New conversation'.
The server only generates a title when conv_title is falsy (empty).
With a non-empty title, should_gen_title is False for the first
message (msg_count % 10 != 0), so auto-naming never fired.

Now creates with empty title (matching web app behaviour). The list
still displays 'New conversation' as a UI placeholder until the
server-generated title arrives.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:46:23 -04:00
bvandeusen d441dcf954 fix(voice): fix silent STT failure — use AAC/M4A, store onError, guard NaN amplitude
Three bugs causing silent STT failure on Android:

1. AudioEncoder.opus produces an OGG container on Android but the file
   was named .webm — faster-whisper rejected it due to format mismatch.
   Changed to AudioEncoder.aacLc + .m4a (reliable on all Android versions).
   Updated voice_api.dart to send audio/mp4 MIME type accordingly.

2. onError callback was never stored, so errors in _startListening() and
   _handleSilence() were silently swallowed. Now stored as _onError and
   called before exitVoiceMode() on any failure.

3. Amplitude stream can emit NaN or ±Infinity on some Android devices
   during recorder initialisation. NaN < -40.0 is false, so silence was
   never detected. Now treated explicitly as silence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:39:21 -04:00
bvandeusen 03dc9108a3 docs: update README to reflect current app features and architecture
Replaces outdated Library/stub descriptions with accurate coverage of
Knowledge, Calendar, News, Voice I/O, chat tool-status notifications,
auto-refresh, and the current file tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:34:20 -04:00
bvandeusen 5014eca9ac feat: auto-refresh data on app resume and tab switch
- App resume: invalidates all main providers (conversations, calendar,
  news, knowledge, briefing) when the app returns to foreground.
  Throttled to once per 5 minutes to avoid hammering the server.
- Tab switch: refreshes the incoming tab's provider when navigating
  between Briefing / Knowledge / Conversations shell tabs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:45:33 -04:00
bvandeusen d530920284 fix: load tasks via TasksApi in Knowledge view, remove tab counts
Tasks are not part of the knowledge API (backend rejects type=task).
When the Tasks tab is selected, fetch from /api/tasks and convert to
KnowledgeItem. Also removes per-type counts from tab labels to prevent
tabs from awkwardly resizing when counts load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 12:36:00 -04:00
bvandeusen e2a358a158 feat: show tool status notifications in chat during generation
Parse SSE `status` events alongside `chunk` events and display the
status text next to the spinner while the assistant is generating.
Previously the Android app discarded all non-chunk SSE events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:46:42 -04:00
bvandeusen 4919f7a185 feat: wire Calendar route to CalendarScreen 2026-04-06 10:32:53 -04:00
bvandeusen 5b639dbd4c feat: add CalendarScreen with month strip and agenda list 2026-04-06 10:32:13 -04:00
bvandeusen 334882520c feat: add EventFormSheet with CRUD, recurrence picker, and color chips
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:31:08 -04:00
bvandeusen c4dca6d4ed feat: add CalendarNotifier and calendarProvider 2026-04-06 10:28:53 -04:00
bvandeusen 776b394874 feat: add EventsApi and eventsApiProvider 2026-04-06 10:27:11 -04:00
bvandeusen b56c0fc02d feat: add table_calendar package and CalendarEvent model with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:25:52 -04:00
bvandeusen 8a0837a843 docs: add Android Calendar screen implementation plan 2026-04-06 10:17:29 -04:00
bvandeusen 8e5a95b0f2 docs: add Android Calendar screen design spec 2026-04-06 10:01:37 -04:00
bvandeusen 3a07221968 feat: wire News route to NewsScreen 2026-04-06 08:30:08 -04:00
bvandeusen e7d174cef7 feat: add NewsScreen with feed filter, reactions, and discuss 2026-04-06 08:29:30 -04:00
bvandeusen f4e39c00eb fix: use ref.watch in build() and avoid stale snapshot in loadMore catch 2026-04-06 08:26:59 -04:00
bvandeusen e08a8906e3 feat: add NewsNotifier and feedsProvider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 08:22:24 -04:00
bvandeusen 01ea6b48db feat: add RssItemMeta.fromNewsItem adapter factory 2026-04-06 08:18:34 -04:00
bvandeusen b56f3d3a0f feat: add NewsApi, openArticleInChat, and newsApiProvider 2026-04-06 08:16:00 -04:00
bvandeusen 77fc82af45 feat: add NewsItem and BriefingFeed models with tests 2026-04-06 08:13:06 -04:00
bvandeusen a2fc0d6c7d docs: add Android news screen implementation plan 2026-04-06 08:11:12 -04:00
bvandeusen 2a2f9e6e85 docs: add Android news screen design spec 2026-04-06 08:04:18 -04:00
bvandeusen 95d0f529ea fix: invalidate project tasks on return from task edit screen 2026-04-06 07:15:35 -04:00
bvandeusen 36bc36cd9d feat: replace Projects tab with More bottom sheet (news/calendar stubs) 2026-04-06 07:14:38 -04:00
bvandeusen a23af0658a docs: add Android nav restructure implementation plan 2026-04-06 06:40:06 -04:00
bvandeusen 39d9f7e053 docs: add Android nav restructure + projects staleness fix spec 2026-04-06 06:35:31 -04:00
bvandeusen 9944680c5b Release v26.04.05.1 — Voice I/O 2026-04-05 23:55:13 +00:00
bvandeusen 29ff9f821a fix: override record_linux to 1.3.0 for platform interface compatibility
record_linux 0.7.2 doesn't implement startStream from record_platform_interface 1.5.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:50:05 -04:00
bvandeusen 1b08b2fd9e feat(voice): integrate voice mode into Briefing screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:47:38 -04:00
bvandeusen 211bf0d658 feat(voice): integrate one-shot voice capture into Quick Capture bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:46:34 -04:00
bvandeusen 2231c60bfb feat(voice): integrate voice mode into Chat screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:45:53 -04:00
bvandeusen 81077349a5 feat(voice): add VoiceMicButton animated widget
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:44:55 -04:00
bvandeusen 2cb566336b feat(voice): add VoiceNotifier with recording, silence detection, and streaming TTS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:44:21 -04:00
bvandeusen 6e33d74178 feat(voice): add VoiceRepository and provider registration 2026-04-05 14:40:49 -04:00
bvandeusen c90b0b3d48 feat(voice): add VoiceApi and VoiceStatus model 2026-04-05 14:40:19 -04:00
bvandeusen e891e8ba52 feat(voice): add record + just_audio dependencies, RECORD_AUDIO permission 2026-04-05 14:39:35 -04:00
bvandeusen 89fe8994fb docs: add Android Voice I/O implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:36:48 -04:00
bvandeusen 3a3f44b00b docs: add Android Voice I/O design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 14:19:48 -04:00
bvandeusen d97f7b0ebd feat(knowledge): wire 4-tab shell, retire LibraryScreen, complete Knowledge View feature 2026-04-05 00:03:23 -04:00
bvandeusen 2ab24a99a8 feat(knowledge): add edit button to ProjectTasksScreen app bar 2026-04-04 23:56:40 -04:00
bvandeusen e39d31fe43 feat(knowledge): add ProjectsScreen and ProjectEditScreen, sort projects by updated_at 2026-04-04 23:56:18 -04:00
bvandeusen a50193dbc0 feat(knowledge): update Project model, ProjectsApi sort, and NoteEditScreen noteType support 2026-04-04 23:52:10 -04:00
bvandeusen f5ba2d25a3 feat(knowledge): add KnowledgeScreen with tabs, search, tag filters, and infinite scroll 2026-04-04 23:50:19 -04:00
bvandeusen b5d9efa3ec feat(knowledge): add KnowledgeItemCard widget 2026-04-04 23:49:18 -04:00
bvandeusen e0b56fc149 feat(knowledge): add KnowledgeNotifier with two-tier pagination state 2026-04-04 23:48:47 -04:00
bvandeusen 535833abfe test: add KnowledgeItem model unit tests 2026-04-04 23:40:29 -04:00
bvandeusen deec2318f7 feat(knowledge): add KnowledgeApi, KnowledgeRepository, wire providers 2026-04-04 23:39:43 -04:00
bvandeusen 2920252f13 feat(knowledge): add KnowledgeItem model 2026-04-04 23:38:19 -04:00
bvandeusen c8cdcbf230 feat(knowledge): add noteType field to Note model, api, repository, provider 2026-04-04 23:37:53 -04:00
bvandeusen e89626a782 feat(knowledge): add knowledge + projectEdit route constants 2026-04-04 22:55:50 -04:00
bvandeusen ac4b2359a5 docs: add Knowledge View design spec for Android app
Covers four-tab navigation (Briefing/Knowledge/Chat/Projects),
two-tier pagination (50 IDs / 12-item batches), type-aware cards,
bottom sheet type picker, Projects screen with sorted project list,
ProjectEditScreen, and manifest/config checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 22:30:44 -04:00
bvandeusen f11e869a1b Merge pull request 'Release v26.03.27.1' (#11) from dev into main
Release v26.03.27.1
2026-03-27 04:12:30 +00:00
bvandeusen 23509adfa8 feat: news story cards in Android briefing screen
Replaces bare Story-N reaction rows with full NewsCard widgets: source label,
relative timestamp, linked headline, 2-line snippet, and 👍/👎 reactions.
Reads rss_items from message metadata (requires backend ≥ this sprint).

Adds url_launcher ^6.3.1 for opening article links in the browser.
Adds https/http <queries> entries to AndroidManifest for Android 11+.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 00:08:51 -04:00
97 changed files with 22868 additions and 2108 deletions
+43 -57
View File
@@ -1,6 +1,12 @@
# CI runs only on release tags.
# CI runs first; build only proceeds if all checks pass.
#
# Tag v*: analyze + test → build APK → attach to Forgejo Release
# Push to dev: flutter analyze + flutter test
# Tag v* (release): gates + signed APK build + attach to Forgejo Release
#
# main pushes are NOT gated here: a merge to main only happens after
# dev has already passed CI, and the release tag is the sole trigger
# for a signed APK build. Re-running analyze+test on the merge commit
# just burns runner time without changing the outcome.
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
@@ -11,19 +17,39 @@
# commands directly instead.
#
# Required secrets (repo → Settings → Secrets → Actions):
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# RELEASE_KEYSTORE_BASE64 — base64 of the signing keystore
# RELEASE_KEYSTORE_PASSWORD — keystore + key password
# RELEASE_KEY_ALIAS — key alias within the keystore
name: CI & Build
on:
push:
branches: [dev]
tags: ["v*"]
# Cancel older runs on the same branch when a newer push lands. Tag runs
# are never cancelled so a release build can't kill itself mid-flight.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
# Least-privilege default. The build job upgrades to contents: write
# so it can attach the APK to a Forgejo release.
permissions:
contents: read
jobs:
analyze:
name: Analyze & test
runs-on: py3.12-node22
runs-on: ci-runner
container:
image: ghcr.io/cirruslabs/flutter:stable
# Pinned to a specific Flutter version for reproducible builds.
# Floating :stable means a random Flutter minor bump could change
# analyzer output or break the build without any commit landing.
# Bump this line (and verify locally with `flutter --version`)
# when you intentionally want a newer Flutter.
image: ghcr.io/cirruslabs/flutter:3.41.6
steps:
- name: Checkout
run: |
@@ -42,9 +68,14 @@ jobs:
build:
name: Build release APK
needs: [analyze]
runs-on: py3.12-node22
# Only tag pushes produce a signed release build. dev pushes
# run the gates above and stop there.
if: startsWith(github.ref, 'refs/tags/')
runs-on: ci-runner
container:
image: ghcr.io/cirruslabs/flutter:stable
image: ghcr.io/cirruslabs/flutter:3.41.6
permissions:
contents: write
steps:
- name: Checkout
run: |
@@ -74,57 +105,12 @@ jobs:
--build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER"
- name: Set artifact name
id: artifact
run: |
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Upload artifact to Forgejo
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
APK: build/app/outputs/flutter-apk/app-release.apk
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
run: |
# Upload the APK as a workflow artifact via the Forgejo API.
curl -s -X POST "$API/actions/artifacts" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "name=$ARTIFACT_NAME" \
-F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)."
- name: Publish Forgejo release
# Release-publish logic lives in a shell script so it's
# testable locally (bash -x scripts/publish_apk_release.sh
# with env vars set) instead of trapped in YAML.
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
run: |
# Look for an existing release (created via the UI or a prior run).
EXISTING=$(curl -s \
"$API/releases/tags/$TAG" \
-H "Authorization: token $RELEASE_TOKEN")
RELEASE_ID=$(echo "$EXISTING" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -n "$RELEASE_ID" ]; then
echo "Found existing release $TAG (id $RELEASE_ID), attaching APK..."
else
echo "No existing release found, creating $TAG..."
RESPONSE=$(curl -s -X POST "$API/releases" \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}")
RELEASE_ID=$(echo "$RESPONSE" | grep -oE '"id":[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
if [ -z "$RELEASE_ID" ]; then
echo "Failed to create release. API response:"
echo "$RESPONSE"
exit 1
fi
echo "Release created with id $RELEASE_ID."
fi
curl -s -X POST "$API/releases/$RELEASE_ID/assets" \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@build/app/outputs/flutter-apk/app-release.apk"
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
APK: build/app/outputs/flutter-apk/app-release.apk
run: bash scripts/publish_apk_release.sh
+1
View File
@@ -49,3 +49,4 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
.superpowers/
+44 -22
View File
@@ -6,11 +6,16 @@ Native Android client for FabledAssistant, a self-hosted AI second-brain and pro
- **Daily Briefing** — the primary screen; opens on launch. Shows your AI-compiled morning digest (tasks, calendar, weather, RSS) with a full conversation you can reply to inline. Refresh manually or browse past briefings from the overflow menu.
- **Quick Capture** — always-visible input bar above all tabs. Type a note or task and submit; multiple captures queue sequentially so the input is never blocked. Falls back to offline persistence when the server is unreachable.
- **Library** — unified browsable list of notes, tasks, and projects with filter pills (All · Notes · Tasks · Projects). Tasks have a secondary status sub-filter and a tappable status icon to cycle todo → in progress → done without opening the editor. Inline search filters results live.
- **Chat** — streaming AI conversations with real-time SSE display. Tap + to start a new conversation or open an existing one.
- **Knowledge** — unified browsable feed of notes, people, places, lists, and tasks across six filter tabs. Tag filters, inline search (debounced), and two-tier pagination (ID list → batch hydration). Tasks load from `/api/tasks` directly and are fully integrated with the knowledge feed.
- **Chat** — streaming AI conversations with real-time SSE display, including live tool-use status notifications (e.g. "Calling create_note…"). Tap + to start a new conversation or open an existing one.
- **Calendar** — month strip + daily agenda view backed by the server's internal event store. Full event CRUD with a modal form: title, all-day toggle, start/end date+time pickers, repeat (None/Daily/Weekly/Monthly/Yearly), description, location, and colour chips. Custom RRULE strings are preserved read-only.
- **News** — RSS article feed with per-feed filtering and reactions. Tap "Discuss" to open any article in a new chat conversation.
- **Projects** — browse and edit projects; tap a project to see its milestone-grouped task list.
- **Voice I/O** — tap the microphone in the capture bar or chat to dictate. Server-side STT transcribes audio; TTS reads assistant replies aloud in voice mode.
- **Note & task editing** — full Markdown editor for notes, task editor with due date, priority, project, and milestone assignment.
- **OAuth / SSO** — authenticates via your server's OIDC provider; local username/password login also supported if enabled server-side.
- **Session persistence** — stays logged in across restarts via a persistent cookie jar.
- **Auto-refresh** — data refreshes automatically when the app returns to the foreground (throttled to once per 5 minutes) and when switching between shell tabs.
- **Auto-update** — checks your Forgejo releases on launch and prompts to download and install new APKs in-app.
## Requirements
@@ -39,33 +44,50 @@ On first launch, enter your FabledAssistant server URL (e.g. `https://fabled.exa
```
lib/
├── main.dart # Entry point; resolves async deps before runApp
├── app.dart # GoRouter + auth redirect guards + 3-tab shell
├── main.dart # Entry point; resolves async deps before runApp
├── app.dart # GoRouter + auth guards + 3-tab shell + auto-refresh
├── core/
│ ├── constants.dart # Route name constants
│ ├── exceptions.dart # AppException hierarchy
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
│ ├── constants.dart # Route name constants
│ ├── exceptions.dart # AppException hierarchy
│ └── theme.dart # Custom slate-indigo ColorScheme + Fraunces typography
├── data/
│ ├── api/ # Dio HTTP layer (one class per resource)
│ ├── models/ # Plain Dart models with fromJson/toJson
└── repositories/ # Thin wrappers over API classes
├── providers/ # Riverpod providers (state + dependency wiring)
│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
│ ├── api/ # Dio HTTP layer (one class per resource)
│ ├── chat_api.dart # SSE streaming; typed ChatStreamEvent (text/status)
│ ├── events_api.dart # Calendar event CRUD
│ │ ├── knowledge_api.dart # Two-tier paginated knowledge feed
│ ├── news_api.dart # RSS feed + reactions
│ │ └── voice_api.dart # STT + TTS endpoints
│ ├── models/ # Plain Dart models with fromJson/toJson
│ │ ├── calendar_event.dart # CalendarEvent + dateOnly() helper
│ │ ├── knowledge_item.dart # KnowledgeItem (notes/tasks unified)
│ │ └── message.dart # Chat message with streaming status
│ └── repositories/ # Thin wrappers over API classes
├── providers/ # Riverpod providers (state + dependency wiring)
│ ├── briefing_provider.dart # Today's briefing — optimistic UI, SSE, polling
│ ├── calendar_provider.dart # CalendarNotifier: month navigation + event CRUD
│ ├── chat_provider.dart # Conversations + streaming messages + status
│ ├── knowledge_provider.dart # Two-tier pagination; delegates tasks to TasksApi
│ ├── news_provider.dart # NewsNotifier + FeedsNotifier
│ └── capture_work_queue_provider.dart # Sequential in-memory capture queue
├── screens/
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
│ ├── library/ # LibraryScreen (unified notes/tasks/projects)
│ ├── chat/ # ConversationsTabScreen + ChatScreen
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
│ ├── tasks/ # TaskEditScreen
│ ├── briefing/ # BriefingScreen + BriefingHistoryScreen
│ ├── calendar/ # CalendarScreen (TableCalendar) + EventFormSheet
│ ├── chat/ # ConversationsTabScreen + ChatScreen
│ ├── knowledge/ # KnowledgeScreen (6-tab feed + search + tag filters)
│ ├── library/ # ProjectTasksScreen (milestone-grouped task list)
│ ├── news/ # NewsScreen (feed filter + reactions + discuss)
│ ├── notes/ # NoteDetailScreen + NoteEditScreen
│ ├── projects/ # ProjectsScreen + ProjectEditScreen
│ ├── tasks/ # TaskEditScreen
│ └── settings/ auth/ setup/ splash/
└── widgets/
├── chat_message_bubble.dart # Shared bubble (used by Chat + Briefing)
├── briefing_digest_card.dart # Expandable first-message card
── library_item_card.dart # Note / task / project row widgets
├── chat_message_bubble.dart # Shared bubble (Chat + Briefing); shows tool status
├── knowledge_item_card.dart # Card for notes, people, places, lists, tasks
── news_card.dart # RSS article card with reactions
└── voice_mic_button.dart # Animated mic button (capture bar + chat)
```
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`
**Key packages:** `flutter_riverpod`, `go_router`, `dio` + `cookie_jar`, `google_fonts`, `flutter_markdown_plus`, `table_calendar`, `record`, `just_audio`
## Building a Release APK
@@ -91,7 +113,7 @@ The build job has `needs: [analyze]` — a failed analyze or test blocks the APK
To cut a release:
```bash
git tag v26.03.11 && git push origin v26.03.11
git tag v26.04.06 && git push origin v26.04.06
```
Requires a `RELEASE_TOKEN` secret (Forgejo PAT with `write:repository` scope) set in repo Settings → Secrets → Actions.
+10
View File
@@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
@@ -70,5 +71,14 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- url_launcher: open http/https links in browser -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="http"/>
</intent>
</queries>
</manifest>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,521 @@
# Android Nav Restructure & Projects Staleness Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Projects bottom-nav tab with a "More" bottom sheet giving access to Projects, News, and Calendar; fix stale task data when returning from task edit.
**Architecture:** The shell's `_tabs` list shrinks from 4 to 3 entries; the 4th nav destination ("More") is intercepted in `onDestinationSelected` to show a `showModalBottomSheet` instead of navigating. Projects moves from a ShellRoute to a top-level push route. News and Calendar are added as stub push-routes. `_tabIndex` maps the three overflow paths to index 3 so "More" highlights correctly. The staleness fix moves the task-tap callback out of the stateless `_TaskRow` widget into the parent `ConsumerState` where `ref` is available, using `.then()` to invalidate after pop.
**Tech Stack:** Flutter, GoRouter, Riverpod, Material 3
---
## Files
| File | Action | What changes |
|------|--------|-------------|
| `lib/core/constants.dart` | Modify | Add `news` and `calendar` route constants |
| `lib/app.dart` | Modify | Remove Projects from ShellRoute, add 3 push routes, shrink `_tabs`, add `_showMoreSheet`, update `_tabIndex`, update both nav widgets |
| `lib/screens/library/project_tasks_screen.dart` | Modify | Add `onTap: VoidCallback` to `_TaskRow`; add `_openTask` method to state; pass callback at both call sites |
---
### Task 1: Add route constants
**Files:**
- Modify: `lib/core/constants.dart`
- [ ] **Step 1: Add `news` and `calendar` to `Routes`**
Open `lib/core/constants.dart`. The file currently ends with:
```dart
abstract class Routes {
static const splash = '/';
static const setup = '/setup';
static const login = '/login';
static const notes = '/notes';
static const noteDetail = '/notes/:id';
static const noteEdit = '/notes/:id/edit';
static const noteNew = '/notes/new';
static const tasks = '/tasks';
static const taskNew = '/tasks/new';
static const taskEdit = '/tasks/:id/edit';
static const knowledge = '/knowledge';
static const projects = '/projects';
static const projectEdit = '/projects/:id/edit';
static const conversations = '/chat';
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const projectTasks = '/projects/:id/tasks';
}
```
Add two constants after `briefing`:
```dart
abstract class Routes {
static const splash = '/';
static const setup = '/setup';
static const login = '/login';
static const notes = '/notes';
static const noteDetail = '/notes/:id';
static const noteEdit = '/notes/:id/edit';
static const noteNew = '/notes/new';
static const tasks = '/tasks';
static const taskNew = '/tasks/new';
static const taskEdit = '/tasks/:id/edit';
static const knowledge = '/knowledge';
static const projects = '/projects';
static const projectEdit = '/projects/:id/edit';
static const conversations = '/chat';
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const news = '/news';
static const calendar = '/calendar';
static const projectTasks = '/projects/:id/tasks';
}
```
- [ ] **Step 2: Verify no analysis errors**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/core/constants.dart
```
Expected: `No issues found!`
---
### Task 2: Restructure shell and add routes in `app.dart`
**Files:**
- Modify: `lib/app.dart`
This task has several sub-steps. Make them all before running analyze.
- [ ] **Step 1: Move Projects out of ShellRoute, add three push routes**
Find the `ShellRoute` block (currently lines ~142162):
```dart
ShellRoute(
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
),
GoRoute(
path: Routes.knowledge,
builder: (_, _) => const KnowledgeScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
],
),
```
Replace with (Projects removed from shell; three new top-level routes added after the closing `],` of the ShellRoute):
```dart
ShellRoute(
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
),
GoRoute(
path: Routes.knowledge,
builder: (_, _) => const KnowledgeScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
],
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => Scaffold(
appBar: AppBar(title: const Text('News')),
body: const Center(child: Text('News — coming soon')),
),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => Scaffold(
appBar: AppBar(title: const Text('Calendar')),
body: const Center(child: Text('Calendar — coming soon')),
),
),
```
- [ ] **Step 2: Shrink `_tabs` from 4 to 3 entries**
Find in `_ShellState`:
```dart
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
Routes.projects,
];
```
Replace with:
```dart
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
];
```
- [ ] **Step 3: Update `_tabIndex` to map overflow routes to index 3**
Find:
```dart
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
return 0;
}
```
Replace with:
```dart
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
if (location.startsWith(Routes.projects) ||
location.startsWith(Routes.news) ||
location.startsWith(Routes.calendar)) return 3;
return 0;
}
```
- [ ] **Step 4: Add `_showMoreSheet` method to `_ShellState`**
Add this method anywhere in `_ShellState`, e.g. just before `build`:
```dart
void _showMoreSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
context.push(Routes.projects);
},
),
ListTile(
leading: const Icon(Icons.newspaper_outlined),
title: const Text('News'),
onTap: () {
Navigator.pop(context);
context.push(Routes.news);
},
),
ListTile(
leading: const Icon(Icons.calendar_month_outlined),
title: const Text('Calendar'),
onTap: () {
Navigator.pop(context);
context.push(Routes.calendar);
},
),
],
),
),
);
}
```
- [ ] **Step 5: Update `NavigationRail` — intercept index 3 and swap destination**
Find in the wide-layout branch:
```dart
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: Text('Knowledge'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'),
),
NavigationRailDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: Text('Projects'),
),
],
),
```
Replace with:
```dart
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
),
NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: Text('Knowledge'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: Text('Chat'),
),
NavigationRailDestination(
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: Text('More'),
),
],
),
```
- [ ] **Step 6: Update `NavigationBar` — intercept index 3 and swap destination**
Find in the narrow-layout branch:
```dart
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: 'Knowledge',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: 'Projects',
),
],
),
```
Replace with:
```dart
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: 'Knowledge',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
label: 'More',
),
],
),
```
- [ ] **Step 7: Verify no analysis errors**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/app.dart lib/core/constants.dart
```
Expected: `No issues found!`
- [ ] **Step 8: Commit**
```bash
git add lib/core/constants.dart lib/app.dart
git commit -m "feat: replace Projects tab with More bottom sheet (news/calendar stubs)"
```
---
### Task 3: Fix stale tasks after returning from task edit
**Files:**
- Modify: `lib/screens/library/project_tasks_screen.dart`
- [ ] **Step 1: Add `_openTask` method to `_ProjectTasksScreenState`**
`_ProjectTasksScreenState` is a `ConsumerState` — it has `ref` and `context`. Find the class body (look for `_cycleStatus` method as a landmark) and add `_openTask` as a sibling method:
```dart
void _openTask(int taskId) {
context
.push(Routes.taskEdit.replaceFirst(':id', '$taskId'))
.then((_) => ref.invalidate(projectTasksProvider(widget.projectId)));
}
```
- [ ] **Step 2: Add `onTap` parameter to `_TaskRow`**
Find the `_TaskRow` class definition:
```dart
class _TaskRow extends StatelessWidget {
final Task task;
final TaskStatus effectiveStatus;
final VoidCallback onStatusTap;
const _TaskRow({
required this.task,
required this.effectiveStatus,
required this.onStatusTap,
});
```
Replace with:
```dart
class _TaskRow extends StatelessWidget {
final Task task;
final TaskStatus effectiveStatus;
final VoidCallback onStatusTap;
final VoidCallback onTap;
const _TaskRow({
required this.task,
required this.effectiveStatus,
required this.onStatusTap,
required this.onTap,
});
```
- [ ] **Step 3: Use the `onTap` callback in `InkWell`**
Find inside `_TaskRow.build`:
```dart
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
```
Replace with:
```dart
child: InkWell(
onTap: onTap,
```
- [ ] **Step 4: Pass `onTap` at both `_TaskRow` call sites**
There are two places in `_buildBody` where `_TaskRow` is instantiated. Update both:
**First call site (milestone tasks, around line 170):**
```dart
return _TaskRow(
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
```
**Second call site (unassigned tasks, around line 197):**
```dart
return _TaskRow(
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
```
- [ ] **Step 5: Verify no analysis errors**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/screens/library/project_tasks_screen.dart
```
Expected: `No issues found!`
- [ ] **Step 6: Commit**
```bash
git add lib/screens/library/project_tasks_screen.dart
git commit -m "fix: invalidate project tasks on return from task edit screen"
```
---
### Task 4: Final check
- [ ] **Step 1: Full analyze**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze
```
Expected: `No issues found!`
@@ -0,0 +1,823 @@
# Android News Screen Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the Android News screen — a paginated, feed-filtered list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
**Architecture:** `NewsNotifier` (`AsyncNotifier<NewsState>`) holds the accumulated item list, pagination, reaction map, and selected feed. The screen is a `ConsumerStatefulWidget` at `/news`. Discuss calls `POST /api/chat/from-article/{id}` (same endpoint as web) and navigates to the returned conversation. Reactions reuse `briefingApiProvider`. The existing `NewsCard` widget is used unchanged via a `RssItemMeta.fromNewsItem` adapter factory.
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget
---
## Files
| File | Action | Responsibility |
|------|--------|---------------|
| `lib/data/models/news_item.dart` | Create | `NewsItem` model + `fromJson` |
| `lib/data/models/briefing_feed.dart` | Create | `BriefingFeed` model + `fromJson` |
| `lib/data/api/news_api.dart` | Create | `getNewsItems(...)` + `getFeeds()` Dio calls |
| `lib/data/api/chat_api.dart` | Modify | Add `openArticleInChat(int itemId)` |
| `lib/providers/api_client_provider.dart` | Modify | Add `newsApiProvider` |
| `lib/widgets/news_card.dart` | Modify | Add `RssItemMeta.fromNewsItem` factory |
| `lib/providers/news_provider.dart` | Create | `NewsState`, `NewsNotifier`, `newsProvider`, `feedsProvider` |
| `lib/screens/news/news_screen.dart` | Create | News screen UI |
| `lib/app.dart` | Modify | Replace News stub route with `NewsScreen()` |
| `test/widget_test.dart` | Modify | Add `NewsItem.fromJson` and `BriefingFeed.fromJson` tests |
---
### Task 1: Data models + tests
**Files:**
- Create: `lib/data/models/news_item.dart`
- Create: `lib/data/models/briefing_feed.dart`
- Modify: `test/widget_test.dart`
- [ ] **Step 1: Create `NewsItem` model**
Create `lib/data/models/news_item.dart`:
```dart
class NewsItem {
final int id;
final String title;
final String url;
final String snippet;
final String source;
final DateTime? publishedAt;
final List<String> topics;
final String? reaction;
const NewsItem({
required this.id,
required this.title,
required this.url,
required this.snippet,
required this.source,
this.publishedAt,
required this.topics,
this.reaction,
});
factory NewsItem.fromJson(Map<String, dynamic> json) => NewsItem(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
snippet: json['snippet'] as String? ?? '',
source: json['source'] as String? ?? '',
publishedAt: json['published_at'] != null
? DateTime.tryParse(json['published_at'] as String)
: null,
topics: (json['topics'] as List<dynamic>?)
?.cast<String>()
.toList() ??
[],
reaction: json['reaction'] as String?,
);
}
```
- [ ] **Step 2: Create `BriefingFeed` model**
Create `lib/data/models/briefing_feed.dart`:
```dart
class BriefingFeed {
final int id;
final String title;
final String url;
final String? category;
const BriefingFeed({
required this.id,
required this.title,
required this.url,
this.category,
});
factory BriefingFeed.fromJson(Map<String, dynamic> json) => BriefingFeed(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
category: json['category'] as String?,
);
}
```
- [ ] **Step 3: Write model tests**
Add these groups to `test/widget_test.dart` (before the closing `}`):
```dart
group('NewsItem.fromJson', () {
test('parses all fields', () {
final json = {
'id': 42,
'title': 'Big news',
'url': 'https://example.com/article',
'snippet': 'A short summary.',
'source': 'Example News',
'published_at': '2026-01-15T10:00:00',
'topics': ['tech', 'ai'],
'reaction': 'up',
};
final item = NewsItem.fromJson(json);
expect(item.id, equals(42));
expect(item.title, equals('Big news'));
expect(item.url, equals('https://example.com/article'));
expect(item.snippet, equals('A short summary.'));
expect(item.source, equals('Example News'));
expect(item.publishedAt, equals(DateTime.parse('2026-01-15T10:00:00')));
expect(item.topics, equals(['tech', 'ai']));
expect(item.reaction, equals('up'));
});
test('handles null published_at and reaction', () {
final json = {
'id': 1,
'title': '',
'url': '',
'snippet': '',
'source': '',
'published_at': null,
'topics': <dynamic>[],
'reaction': null,
};
final item = NewsItem.fromJson(json);
expect(item.publishedAt, isNull);
expect(item.reaction, isNull);
expect(item.topics, isEmpty);
});
});
group('BriefingFeed.fromJson', () {
test('parses all fields', () {
final json = {
'id': 7,
'title': 'Hacker News',
'url': 'https://news.ycombinator.com/rss',
'category': 'tech',
};
final feed = BriefingFeed.fromJson(json);
expect(feed.id, equals(7));
expect(feed.title, equals('Hacker News'));
expect(feed.url, equals('https://news.ycombinator.com/rss'));
expect(feed.category, equals('tech'));
});
test('handles null category', () {
final json = {
'id': 8,
'title': 'Feed',
'url': 'https://example.com/rss',
'category': null,
};
final feed = BriefingFeed.fromJson(json);
expect(feed.category, isNull);
});
});
```
Also add these imports at the top of `test/widget_test.dart`:
```dart
import 'package:fabled_app/data/models/news_item.dart';
import 'package:fabled_app/data/models/briefing_feed.dart';
```
- [ ] **Step 4: Run tests**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter test
```
Expected: All tests passed (17 total — 15 existing + 2 new groups = 4 new tests).
- [ ] **Step 5: Commit**
```bash
git add lib/data/models/news_item.dart lib/data/models/briefing_feed.dart test/widget_test.dart
git commit -m "feat: add NewsItem and BriefingFeed models with tests"
```
---
### Task 2: API layer
**Files:**
- Create: `lib/data/api/news_api.dart`
- Modify: `lib/data/api/chat_api.dart`
- Modify: `lib/providers/api_client_provider.dart`
- [ ] **Step 1: Create `NewsApi`**
Create `lib/data/api/news_api.dart`:
```dart
import 'package:dio/dio.dart';
import '../models/briefing_feed.dart';
import '../models/news_item.dart';
import 'api_client.dart';
class NewsApi {
final Dio _dio;
const NewsApi(this._dio);
/// GET /api/briefing/news
/// Returns up to [limit] items starting at [offset], optionally filtered by [feedId].
Future<List<NewsItem>> getNewsItems({
int days = 90,
int limit = 40,
int offset = 0,
int? feedId,
}) async {
try {
final params = <String, dynamic>{
'days': days,
'limit': limit,
'offset': offset,
if (feedId != null) 'feed_id': feedId,
};
final response = await _dio.get(
'/api/briefing/news',
queryParameters: params,
);
final data = response.data as Map<String, dynamic>;
final list = data['items'] as List<dynamic>;
return list
.map((e) => NewsItem.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/feeds
Future<List<BriefingFeed>> getFeeds() async {
try {
final response = await _dio.get('/api/briefing/feeds');
final list = response.data as List<dynamic>;
return list
.map((e) => BriefingFeed.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
```
- [ ] **Step 2: Add `openArticleInChat` to `ChatApi`**
In `lib/data/api/chat_api.dart`, add this method at the end of the `ChatApi` class (before the closing `}`):
```dart
/// POST /api/chat/from-article/{itemId}
/// Creates or retrieves a chat conversation seeded with the article.
/// Returns the conversation_id.
Future<int> openArticleInChat(int itemId) async {
try {
final response =
await _dio.post('/api/chat/from-article/$itemId', data: <String, dynamic>{});
final data = response.data as Map<String, dynamic>;
return data['conversation_id'] as int;
} on DioException catch (e) {
throw dioToApp(e);
}
}
```
- [ ] **Step 3: Add `newsApiProvider` to `api_client_provider.dart`**
In `lib/providers/api_client_provider.dart`, add the import and provider.
Add import after the existing API imports (e.g., after `voice_api.dart`):
```dart
import '../data/api/news_api.dart';
```
Add provider after `voiceRepositoryProvider`:
```dart
final newsApiProvider = Provider<NewsApi>((ref) {
return NewsApi(ref.watch(dioProvider));
});
```
- [ ] **Step 4: Verify**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart
```
Expected: `No issues found!`
- [ ] **Step 5: Commit**
```bash
git add lib/data/api/news_api.dart lib/data/api/chat_api.dart lib/providers/api_client_provider.dart
git commit -m "feat: add NewsApi, openArticleInChat, and newsApiProvider"
```
---
### Task 3: RssItemMeta adapter
**Files:**
- Modify: `lib/widgets/news_card.dart`
`NewsCard` renders `RssItemMeta` objects. Rather than modifying the widget, we add a factory on `RssItemMeta` that converts a `NewsItem`.
- [ ] **Step 1: Add `fromNewsItem` factory**
In `lib/widgets/news_card.dart`, add this import at the top:
```dart
import '../data/models/news_item.dart';
```
Then add this factory inside the `RssItemMeta` class, after the existing `fromJson` factory:
```dart
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
id: item.id,
title: item.title,
url: item.url,
source: item.source,
snippet: item.snippet,
publishedAt: item.publishedAt,
);
```
- [ ] **Step 2: Verify**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/widgets/news_card.dart
```
Expected: `No issues found!`
- [ ] **Step 3: Commit**
```bash
git add lib/widgets/news_card.dart
git commit -m "feat: add RssItemMeta.fromNewsItem adapter factory"
```
---
### Task 4: Providers
**Files:**
- Create: `lib/providers/news_provider.dart`
- [ ] **Step 1: Create `news_provider.dart`**
Create `lib/providers/news_provider.dart`:
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/briefing_feed.dart';
import '../data/models/news_item.dart';
import 'api_client_provider.dart';
// ─── NewsState ────────────────────────────────────────────────────────────────
class NewsState {
final List<NewsItem> items;
final int offset;
final bool hasMore;
final bool loadingMore;
final int? selectedFeedId;
final Map<int, String?> reactions;
const NewsState({
required this.items,
required this.offset,
required this.hasMore,
required this.loadingMore,
required this.selectedFeedId,
required this.reactions,
});
NewsState copyWith({
List<NewsItem>? items,
int? offset,
bool? hasMore,
bool? loadingMore,
Object? selectedFeedId = _sentinel,
Map<int, String?>? reactions,
}) {
return NewsState(
items: items ?? this.items,
offset: offset ?? this.offset,
hasMore: hasMore ?? this.hasMore,
loadingMore: loadingMore ?? this.loadingMore,
selectedFeedId: selectedFeedId == _sentinel
? this.selectedFeedId
: selectedFeedId as int?,
reactions: reactions ?? this.reactions,
);
}
}
const _sentinel = Object();
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
final newsProvider =
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
class NewsNotifier extends AsyncNotifier<NewsState> {
static const _limit = 40;
@override
Future<NewsState> build() async {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
);
return NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: null,
reactions: {for (final item in items) item.id: item.reaction},
);
}
Future<void> loadMore() async {
final current = state.value;
if (current == null || current.loadingMore || !current.hasMore) return;
state = AsyncData(current.copyWith(loadingMore: true));
try {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: current.offset,
feedId: current.selectedFeedId,
);
final updatedReactions = Map<int, String?>.from(current.reactions);
for (final item in items) {
updatedReactions.putIfAbsent(item.id, () => item.reaction);
}
state = AsyncData(current.copyWith(
items: [...current.items, ...items],
offset: current.offset + items.length,
hasMore: items.length == _limit,
loadingMore: false,
reactions: updatedReactions,
));
} catch (e) {
state = AsyncData(current.copyWith(loadingMore: false));
rethrow;
}
}
Future<void> setFeed(int? feedId) async {
state = const AsyncLoading();
try {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
feedId: feedId,
);
state = AsyncData(NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: feedId,
reactions: {for (final item in items) item.id: item.reaction},
));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void toggleReaction(int itemId, String reaction) {
final current = state.value;
if (current == null) return;
final prev = current.reactions[itemId];
final next = prev == reaction ? null : reaction;
state = AsyncData(current.copyWith(
reactions: {...current.reactions, itemId: next},
));
final briefingApi = ref.read(briefingApiProvider);
final future = next == null
? briefingApi.deleteRssReaction(itemId)
: briefingApi.postRssReaction(itemId, next);
future.catchError((_) {
final s = state.value;
if (s != null) {
state = AsyncData(s.copyWith(
reactions: {...s.reactions, itemId: prev},
));
}
});
}
}
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
final feedsProvider =
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
@override
Future<List<BriefingFeed>> build() async {
return ref.read(newsApiProvider).getFeeds();
}
}
```
- [ ] **Step 2: Verify**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/providers/news_provider.dart
```
Expected: `No issues found!`
- [ ] **Step 3: Commit**
```bash
git add lib/providers/news_provider.dart
git commit -m "feat: add NewsNotifier and feedsProvider"
```
---
### Task 5: News screen
**Files:**
- Create: `lib/screens/news/news_screen.dart`
- [ ] **Step 1: Create the screen**
Create `lib/screens/news/news_screen.dart`:
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/models/briefing_feed.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/news_provider.dart';
import '../../widgets/news_card.dart';
class NewsScreen extends ConsumerStatefulWidget {
const NewsScreen({super.key});
@override
ConsumerState<NewsScreen> createState() => _NewsScreenState();
}
class _NewsScreenState extends ConsumerState<NewsScreen> {
final Set<int> _openingChat = {};
Future<void> _handleDiscuss(int itemId) async {
if (_openingChat.contains(itemId)) return;
setState(() => _openingChat.add(itemId));
try {
final conversationId =
await ref.read(chatApiProvider).openArticleInChat(itemId);
if (mounted) {
context.push(Routes.chat.replaceFirst(':id', '$conversationId'));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to open article in chat.')),
);
}
} finally {
if (mounted) setState(() => _openingChat.remove(itemId));
}
}
Future<void> _loadMore() async {
try {
await ref.read(newsProvider.notifier).loadMore();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to load more articles.')),
);
}
}
}
@override
Widget build(BuildContext context) {
final newsAsync = ref.watch(newsProvider);
final feedsAsync = ref.watch(feedsProvider);
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('News', style: Theme.of(context).textTheme.titleLarge),
Text(
'Last 90 days',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
body: newsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Could not load news."),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(newsProvider),
child: const Text('Retry'),
),
],
),
),
data: (news) => Column(
children: [
_FeedFilter(
feeds: feedsAsync.value ?? [],
selectedFeedId: news.selectedFeedId,
onChanged: (feedId) =>
ref.read(newsProvider.notifier).setFeed(feedId),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
itemCount: news.items.length + 1,
itemBuilder: (_, i) {
if (i == news.items.length) {
if (!news.hasMore) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: news.loadingMore
? const CircularProgressIndicator()
: FilledButton.tonal(
onPressed: _loadMore,
child: const Text('Load more'),
),
),
);
}
final item = news.items[i];
return NewsCard(
item: RssItemMeta.fromNewsItem(item),
reaction: news.reactions[item.id],
onReaction: (itemId, reaction) => ref
.read(newsProvider.notifier)
.toggleReaction(itemId, reaction),
onDiscuss: _openingChat.contains(item.id)
? null
: () => _handleDiscuss(item.id),
);
},
),
),
],
),
),
);
}
}
class _FeedFilter extends StatelessWidget {
final List<BriefingFeed> feeds;
final int? selectedFeedId;
final void Function(int? feedId) onChanged;
const _FeedFilter({
required this.feeds,
required this.selectedFeedId,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
child: Row(
children: [
Text(
'Feed:',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
DropdownButton<int?>(
value: selectedFeedId,
underline: const SizedBox.shrink(),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('All feeds'),
),
...feeds.map(
(f) => DropdownMenuItem<int?>(
value: f.id,
child: Text(f.title),
),
),
],
onChanged: (v) => onChanged(v),
),
],
),
);
}
}
```
- [ ] **Step 2: Verify**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze lib/screens/news/news_screen.dart
```
Expected: `No issues found!`
- [ ] **Step 3: Commit**
```bash
git add lib/screens/news/news_screen.dart
git commit -m "feat: add NewsScreen with feed filter, reactions, and discuss"
```
---
### Task 6: Wire route and final verification
**Files:**
- Modify: `lib/app.dart`
- [ ] **Step 1: Replace the News stub route with `NewsScreen`**
In `lib/app.dart`, add the import near the other screen imports:
```dart
import 'screens/news/news_screen.dart';
```
Find and replace the stub route:
```dart
GoRoute(
path: Routes.news,
builder: (_, _) => Scaffold(
appBar: AppBar(title: const Text('News')),
body: const Center(child: Text('News — coming soon')),
),
),
```
Replace with:
```dart
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
```
- [ ] **Step 2: Full analyze and tests**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabled_app
flutter analyze
flutter test
```
Expected: `No issues found!` and all tests passed.
- [ ] **Step 3: Commit**
```bash
git add lib/app.dart
git commit -m "feat: wire News route to NewsScreen"
```
@@ -0,0 +1,455 @@
# Knowledge View — Android App Design Spec
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the Library tab with a typed Knowledge view and add a dedicated Projects tab, bringing the Android app to parity with the web Knowledge view while using the existing backend `/api/knowledge` and `/api/projects` endpoints.
**Architecture:** Two-tier pagination — fetch IDs cheaply (50 at a time), hydrate visible items in batches of 12. Type tabs drive server-side filtering. A new Projects tab replaces the project section formerly in Library. All data access goes through the existing Riverpod/Dio/repository pattern.
**Tech Stack:** Flutter 3, Riverpod 3, GoRouter 17, Dio 5, existing backend REST API (`/api/knowledge`, `/api/projects`, `/api/notes`)
---
## Manifest & Configuration Checklist
These must be verified before any code is written. Failures here cause silent runtime errors.
- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `<uses-permission android:name="android.permission.INTERNET" />` is present
- [ ] `android/app/src/main/AndroidManifest.xml` — confirm `android:usesCleartextTraffic="true"` is set on the `<application>` tag (required for HTTP dev server connections; if already using HTTPS only, leave as-is but document the decision)
- [ ] `pubspec.yaml` — no new dependencies required for Knowledge View
- [ ] `android/app/build.gradle` — no changes required for Knowledge View
---
## File Map
### New files
| Path | Responsibility |
|---|---|
| `lib/data/models/knowledge_item.dart` | Unified model for all knowledge types (note, person, place, list, task) |
| `lib/data/api/knowledge_api.dart` | Two API methods: fetch IDs, batch-hydrate items |
| `lib/data/repositories/knowledge_repository.dart` | Thin repo wrapping KnowledgeApi |
| `lib/providers/knowledge_provider.dart` | StateNotifier with two-tier pagination state |
| `lib/screens/knowledge/knowledge_screen.dart` | Main Knowledge screen with type tabs, tag chips, search, infinite scroll |
| `lib/widgets/knowledge_item_card.dart` | Type-aware card widget (different icon/subtitle per type) |
| `lib/screens/projects/projects_screen.dart` | Project list sorted by updated_at desc |
| `lib/screens/projects/project_edit_screen.dart` | Create/edit project (title, description, goal, status) |
### Modified files
| Path | Change |
|---|---|
| `lib/data/models/note.dart` | Add `noteType` field (String, default `'note'`) |
| `lib/data/models/project.dart` | Add `status`, `goal`, `color`, `autoSummary`, `updatedAt` fields |
| `lib/data/api/notes_api.dart` | Pass `note_type` in create and update request bodies |
| `lib/data/api/projects_api.dart` | Add `sort=updated_at&order=desc` to list call; add `status`, `goal`, `color` to create/update |
| `lib/screens/notes/note_edit_screen.dart` | Accept optional `noteType` parameter; show type badge in app bar; pass `note_type` to API |
| `lib/screens/library/project_tasks_screen.dart` | Add edit button in app bar pushing to `ProjectEditScreen` |
| `lib/app.dart` | Replace `Routes.library` with `Routes.knowledge` + `Routes.projects`; update shell to 4 tabs |
| `lib/core/constants.dart` | Add `Routes.knowledge`, `Routes.projects`; remove `Routes.library` |
### Retired files
| Path | Replacement |
|---|---|
| `lib/screens/library/library_screen.dart` | `KnowledgeScreen` + `ProjectsScreen` |
| `lib/widgets/library_item_card.dart` | `KnowledgeItemCard` |
---
## Section 1: Navigation
Four tabs replace the existing three:
```
Briefing | Knowledge | Chat | Projects
```
**`lib/core/constants.dart`:**
- Remove `static const library = '/library'`
- Add `static const knowledge = '/knowledge'`
- `projects` already exists as `'/projects'` — no change needed
- Add `static const projectEdit = '/projects/:id/edit'`
**`lib/app.dart``_ShellState`:**
```dart
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
Routes.projects,
];
```
Shell `NavigationBar` / `NavigationRail` entries:
```dart
// Bottom nav
NavigationDestination(icon: Icon(Icons.wb_sunny_outlined), selectedIcon: Icon(Icons.wb_sunny), label: 'Briefing'),
NavigationDestination(icon: Icon(Icons.menu_book_outlined), selectedIcon: Icon(Icons.menu_book), label: 'Knowledge'),
NavigationDestination(icon: Icon(Icons.chat_bubble_outline), selectedIcon: Icon(Icons.chat_bubble), label: 'Chat'),
NavigationDestination(icon: Icon(Icons.folder_outlined), selectedIcon: Icon(Icons.folder), label: 'Projects'),
```
**`_QuickCaptureBar._hintForLocation` update:**
```dart
String _hintForLocation(String location) {
if (location.startsWith(Routes.knowledge)) return 'Capture a note…';
if (location.startsWith(Routes.projects)) return 'Capture a note…';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
```
**GoRouter shell routes** — replace `Routes.library` route with:
```dart
GoRoute(path: Routes.knowledge, builder: (_, _) => const KnowledgeScreen()),
GoRoute(path: Routes.projects, builder: (_, _) => const ProjectsScreen()),
```
---
## Section 2: Data Models
### `lib/data/models/knowledge_item.dart`
```dart
class KnowledgeItem {
final int id;
final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task'
final String title;
final String body;
final List<String> tags;
final int? projectId;
final int? milestoneId;
final int? parentId;
// Task-only fields (null for non-tasks)
final String? status; // 'todo' | 'in_progress' | 'done' | 'cancelled'
final String? priority; // 'low' | 'normal' | 'high'
final String? dueDate;
final DateTime createdAt;
final DateTime updatedAt;
const KnowledgeItem({...});
factory KnowledgeItem.fromJson(Map<String, dynamic> json) => KnowledgeItem(
id: json['id'] as int,
noteType: json['note_type'] as String? ?? 'note',
title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '',
tags: (json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
projectId: json['project_id'] as int?,
milestoneId: json['milestone_id'] as int?,
parentId: json['parent_id'] as int?,
status: json['status'] as String?,
priority: json['priority'] as String?,
dueDate: json['due_date'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
```
### `lib/data/models/note.dart` — add `noteType`
```dart
final String noteType; // new field
// In constructor:
required this.noteType,
// In fromJson:
noteType: json['note_type'] as String? ?? 'note',
// In toJson:
'note_type': noteType,
// In copyWith: add noteType parameter
```
### `lib/data/models/project.dart` — add missing fields
```dart
final String status; // 'active' | 'completed' | 'archived'
final String? goal;
final String? color;
final String? autoSummary;
final DateTime updatedAt;
// In fromJson:
status: json['status'] as String? ?? 'active',
goal: json['goal'] as String?,
color: json['color'] as String?,
autoSummary: json['auto_summary'] as String?,
updatedAt: DateTime.parse(json['updated_at'] as String),
```
---
## Section 3: API Layer
### `lib/data/api/knowledge_api.dart`
```dart
class KnowledgeApi {
final Dio _dio;
const KnowledgeApi(this._dio);
/// Fetch page of IDs. Returns (ids, total).
Future<(List<int>, int)> fetchIds({
String? noteType,
List<String> tags = const [],
String sort = 'modified',
String? q,
int limit = 50,
int offset = 0,
}) async {
final params = <String, dynamic>{
'limit': limit,
'offset': offset,
'sort': sort,
if (noteType != null) 'type': noteType,
if (tags.isNotEmpty) 'tags': tags.join(','),
if (q != null && q.isNotEmpty) 'q': q,
};
final response = await _dio.get('/api/knowledge/ids', queryParameters: params);
final data = response.data as Map<String, dynamic>;
final ids = (data['ids'] as List<dynamic>).map((e) => e as int).toList();
final total = data['total'] as int;
return (ids, total);
}
/// Batch-hydrate up to 100 IDs into full items.
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) async {
if (ids.isEmpty) return [];
final response = await _dio.get(
'/api/knowledge/batch',
queryParameters: {'ids': ids.join(',')},
);
final data = response.data as Map<String, dynamic>;
return (data['items'] as List<dynamic>)
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Per-type counts for tab labels.
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
final params = <String, dynamic>{
if (tags.isNotEmpty) 'tags': tags.join(','),
};
final response = await _dio.get('/api/knowledge/counts', queryParameters: params);
return (response.data as Map<String, dynamic>).map(
(k, v) => MapEntry(k, (v as num).toInt()),
);
}
/// All tags for the current type filter.
Future<List<String>> fetchTags({String? noteType}) async {
final response = await _dio.get(
'/api/knowledge/tags',
queryParameters: {if (noteType != null) 'type': noteType},
);
return (response.data['tags'] as List<dynamic>).map((e) => e as String).toList();
}
}
```
### `lib/data/api/projects_api.dart` — add sort params + missing fields
Add `sort: 'updated_at'` and `order: 'desc'` to the `getProjects` query parameters.
Add `status`, `goal`, `color` to `createProject` and `updateProject` request bodies.
### `lib/data/api/notes_api.dart` — pass `note_type`
```dart
// In createNote and updateNote bodies:
if (noteType != null) 'note_type': noteType,
```
---
## Section 4: Provider
### `lib/providers/knowledge_provider.dart`
```dart
@immutable
class KnowledgeState {
final List<int> ids; // all fetched IDs so far
final Map<int, KnowledgeItem> items; // hydrated items
final int totalIds;
final bool isLoadingIds;
final bool isLoadingBatch;
final bool hasMore;
final String? noteType; // active type filter
final List<String> activeTags;
final String? searchQuery;
final Map<String, int> counts; // per-type counts
bool get canLoadMore => hasMore && !isLoadingIds;
// Items in ID order, only those hydrated
List<KnowledgeItem> get orderedItems =>
ids.where(items.containsKey).map((id) => items[id]!).toList();
}
class KnowledgeNotifier extends StateNotifier<KnowledgeState> {
// On filter change: reset state, fetch IDs from offset 0
void setTypeFilter(String? noteType) { ... }
void toggleTag(String tag) { ... }
void setSearch(String? q) { ... } // debounce handled in UI
// Fetch next page of IDs (50 at a time)
Future<void> loadMoreIds() { ... }
// Hydrate the next 12 un-hydrated IDs from the current list
Future<void> hydrateNext() { ... }
Future<void> refresh() { ... } // reset + reload
}
// Provider
final knowledgeProvider =
StateNotifierProvider<KnowledgeNotifier, KnowledgeState>(...);
```
**Scroll trigger:** `KnowledgeScreen` attaches a `ScrollController` listener. When `position.pixels >= maxScrollExtent - 300`:
1. Call `hydrateNext()` if there are un-hydrated IDs in the list
2. Call `loadMoreIds()` if all fetched IDs are hydrated and `hasMore` is true
---
## Section 5: Knowledge Screen
### `lib/screens/knowledge/knowledge_screen.dart`
**Structure:**
```
Scaffold
AppBar
title: 'Knowledge'
actions: [search icon → expand TextField]
Column
TabBar (All | Notes | People | Places | Lists | Tasks)
— tab labels show counts: "Notes (12)"
AnimatedContainer (tag filter chip row, hidden when empty)
— horizontal SingleChildScrollView of FilterChip widgets
Expanded
ListView.builder
— items from knowledgeState.orderedItems
— trailing: loading indicator when isLoadingBatch
FAB (pen icon)
— Tasks tab → push TaskEditScreen
— all other tabs → showModalBottomSheet (type picker)
```
**Pull-to-refresh:** `RefreshIndicator` wrapping the `ListView`.
**Empty state:** Centered column with type-appropriate icon and "No [type] yet" text.
**Error state:** Centered text with retry button calling `ref.invalidate(knowledgeProvider)`.
### `lib/widgets/knowledge_item_card.dart`
`ListTile`-based card. Leading icon varies by type:
- note → `Icons.description_outlined`
- person → `Icons.person_outlined`
- place → `Icons.place_outlined`
- list → `Icons.checklist_outlined`
- task → `Icons.task_alt_outlined` (with status colour on leading)
Subtitle shows truncated body (max 2 lines) or due date for tasks.
Trailing shows tag chips (up to 2, then "+N more").
Tap → `NoteDetailScreen(noteId: item.id)` for knowledge types; `TaskEditScreen(taskId: item.id)` for tasks.
---
## Section 6: Type Picker Bottom Sheet
Shown when FAB is tapped on any non-Tasks tab.
```dart
showModalBottomSheet(
context: context,
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: [
_TypeRow(Icons.description_outlined, 'Note', 'General note or document', 'note'),
_TypeRow(Icons.person_outlined, 'Person', 'Contact, colleague, or reference person', 'person'),
_TypeRow(Icons.place_outlined, 'Place', 'Location, venue, or place of interest', 'place'),
_TypeRow(Icons.checklist_outlined, 'List', 'Checklist or structured list', 'list'),
],
),
);
```
Each `_TypeRow` on tap:
1. `Navigator.pop(context)`
2. `context.push(Routes.noteNew, extra: {'noteType': selectedType})`
### `lib/screens/notes/note_edit_screen.dart` changes
- Accept `noteType` from `GoRouterState.extra` or route parameter (default `'note'`)
- Display a read-only type badge chip in the app bar subtitle
- Pass `noteType` to `notes_api.createNote` / `notes_api.updateNote`
---
## Section 7: Projects Screen
### `lib/screens/projects/projects_screen.dart`
```
Scaffold
AppBar: 'Projects'
RefreshIndicator
ListView.builder
— projects from projectsProvider (sorted updated_at desc via API param)
— each item: _ProjectCard
FAB → push ProjectEditScreen()
```
`_ProjectCard` (inline widget):
- Title + status badge (`active` = green, `completed` = blue, `archived` = grey)
- Description (1 line, truncated)
- Goal text if present (italic, muted)
- Milestone progress: `"3 / 5 milestones done"` using milestone count from project data if available, otherwise omitted
Tap → `context.push('/projects/${project.id}/tasks')``ProjectTasksScreen` reads `projectId` from the path parameter, unchanged except for the added edit button.
### `lib/screens/projects/project_edit_screen.dart`
Fields: Title (required), Description, Goal, Status dropdown (`active` / `completed` / `archived`).
Used for both create (no `projectId`) and edit (with `projectId`).
On save: POST `/api/projects` or PATCH `/api/projects/:id` via `projectsApi`.
On success: `ref.invalidate(projectsProvider)` then `Navigator.pop`.
### `lib/screens/library/project_tasks_screen.dart` change
Add an edit `IconButton` in the `AppBar.actions`:
```dart
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.push('/projects/$projectId/edit'),
)
```
---
## Section 8: Provider & API Wiring
New providers to add to `lib/providers/`:
```dart
// knowledge_api_provider.dart (or in api_client_provider.dart)
final knowledgeApiProvider = Provider((ref) =>
KnowledgeApi(ref.watch(dioProvider)));
final knowledgeRepositoryProvider = Provider((ref) =>
KnowledgeRepository(ref.watch(knowledgeApiProvider)));
```
`projectsProvider` — add query parameters to the underlying `getProjects` call:
```dart
await api.getProjects(sort: 'updated_at', order: 'desc');
```
---
## What This Does NOT Include
- Voice I/O (separate spec)
- Backend parity pass (separate spec)
- Editing knowledge item type after creation
- Bulk delete / multi-select in Knowledge screen
- Knowledge graph view
- Milestone detail screen (projects show milestone count only)
@@ -0,0 +1,180 @@
# Android Voice I/O Design
## Goal
Add voice input (STT) and output (TTS) to the Android app. All audio processing runs server-side via existing backend endpoints — no on-device STT or TTS APIs. Voice input is available in three locations: the Chat screen, the Quick Capture bar, and the Briefing follow-up bar. TTS auto-plays when voice mode is active.
---
## Architecture
### New files
| File | Responsibility |
|---|---|
| `lib/data/api/voice_api.dart` | Dio wrapper: `checkStatus()`, `transcribe(Uint8List)`, `synthesise(String)` |
| `lib/data/repositories/voice_repository.dart` | Thin wrapper around `VoiceApi` |
| `lib/providers/voice_provider.dart` | `VoiceState` + `VoiceNotifier extends Notifier<VoiceState>` — owns full recording/TTS lifecycle |
| `lib/widgets/voice_mic_button.dart` | Shared mic button widget used by all three screens; animates across all states |
### Modified files
| File | Change |
|---|---|
| `pubspec.yaml` | Add `record: ^6.x`, `just_audio: ^0.9.x` |
| `android/app/src/main/AndroidManifest.xml` | Add `RECORD_AUDIO` permission |
| `lib/providers/api_client_provider.dart` | Add `voiceApiProvider`, `voiceRepositoryProvider` |
| `lib/screens/chat/chat_screen.dart` | Add `VoiceMicButton` to input bar; wire streaming TTS watcher |
| `lib/app.dart` (`_QuickCaptureBar`) | Add `VoiceMicButton` next to capture send button |
| `lib/screens/briefing/briefing_screen.dart` | Add `VoiceMicButton` to follow-up input row; wire streaming TTS watcher |
### Packages
- **`record` ^6.x** — records audio on Android, outputs WebM/Opus (matches what the backend Whisper STT expects)
- **`just_audio` ^0.9.x** — queue-based audio player; plays WAV bytes returned by `/api/voice/synthesise`
---
## State model
```dart
enum VoiceMode { idle, recording, transcribing, playing }
class VoiceState {
final VoiceMode mode;
final bool voiceModeActive; // whether the voice loop is running
final bool available; // from /api/voice/status check
}
```
`VoiceNotifier` is a `Notifier<VoiceState>` (Riverpod 3). It owns the `AudioRecorder` and `AudioPlayer` instances.
---
## Backend endpoints (no changes needed)
All existing, no backend work required:
- `GET /api/voice/status``{enabled, stt, tts}` — checked before entering voice mode
- `POST /api/voice/transcribe``multipart/form-data`, field `audio` (WebM/Opus bytes) → `{transcript, duration_ms}`
- `POST /api/voice/synthesise``{"text": "..."}``audio/wav` bytes
---
## Data flow
### Chat / Briefing — continuous voice loop
1. User taps mic → `VoiceNotifier.enterVoiceMode()`
2. Call `GET /api/voice/status`; if unavailable → show snackbar, abort
3. Request `RECORD_AUDIO` permission (via `permission_handler`); if denied → snackbar, abort
4. Set `voiceModeActive = true`; input field disabled, send button dimmed, red banner shown
5. Start recording via `record` package; poll amplitude every 200ms
6. Silence detected (amplitude < 40 dB for 1500ms) → stop recording
7. `POST /api/voice/transcribe` with WebM bytes → transcript
8. If transcript is empty → restart listening from step 5 silently
9. Call `sendMessage(transcript)` on `messagesProvider` (identical path to typed text)
10. As the SSE response streams in, `VoiceNotifier` watches the streaming content:
- Buffer incoming text; extract completed sentences at `.`, `!`, `?` boundaries
- Strip markdown (code fences, headers, bold/italic markers) before synthesising
- For each sentence: `POST /api/voice/synthesise` → enqueue WAV blob in `just_audio` player
11. After all audio plays → restart listening from step 5
12. User taps mic again → `VoiceNotifier.exitVoiceMode()` → stop recording, cancel pending TTS, reset state
### Quick Capture — one-shot, no TTS loop
Steps 18 identical. Then instead of `sendMessage`, the transcript is passed to `captureWorkQueueProvider.enqueue(transcript)` — identical to typing in the capture bar. Mic returns to idle. No TTS playback, no loop.
---
## Silence detection
The `record` package emits `onAmplitudeChanged` events. `VoiceNotifier` tracks consecutive below-threshold samples:
- Threshold: 40 dB
- Required duration: 1500ms of continuous silence
- Minimum recording length: 300ms (ignore silence before user has spoken)
---
## Streaming TTS (mirrors web app)
Matches the behaviour of `useStreamingTts` in the web frontend:
1. Watch `messagesProvider` for streaming assistant content
2. Accumulate new characters into a sentence buffer
3. On each sentence boundary → strip markdown → `POST /api/voice/synthesise` → enqueue WAV
4. `just_audio` plays queued WAVs sequentially
5. On new message start → cancel in-flight synthesis, clear queue, stop playback
6. On stream end → flush remaining buffer fragment if ≥ 3 characters
Markdown stripping removes: code blocks (`` ``` ``), inline code, `#` headers, `**bold**`, `*italic*`, `[link](url)` → link text only, leading list markers.
---
## UX
### VoiceMicButton states
| State | Visual |
|---|---|
| Idle (voice mode off) | Plain mic icon, muted background |
| Recording | Red filled circle, pulsing shadow ring |
| Transcribing | Indigo filled circle, spinner overlay |
| Playing TTS | Indigo filled circle, speaker wave icon |
### Voice mode banner (Chat + Briefing only)
A thin red banner appears above the input row while voice mode is active:
> "🎤 Listening… tap mic to exit voice mode"
Input field shows "Listening…" hint in italic. Send button dimmed but still tappable as an override.
### Quick Capture bar
No banner. The capture bar's background shifts to a subtle red tint while recording. Returns to normal after capture.
---
## Error handling
| Scenario | Behaviour |
|---|---|
| Voice unavailable on server | Snackbar "Voice not available on this server", mic stays idle |
| Mic permission denied | Snackbar "Microphone permission required", mic stays idle |
| Empty transcript | Stay in voice mode, restart listening silently |
| Transcription API error | Stay in voice mode, restart listening; log warning |
| TTS synthesis failure for a sentence | Skip that sentence, continue playback queue |
| Network error during voice loop | Exit voice mode, show snackbar "Voice error — check connection" |
| User navigates away while in voice mode | `VoiceNotifier` disposes recording + playback cleanly |
---
## Permissions
Add to `android/app/src/main/AndroidManifest.xml`:
```xml
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
```
Runtime permission requested via `permission_handler` at first mic tap. If permanently denied, open app settings.
---
## Testing
- **Unit — `VoiceNotifier`:** Mock `VoiceRepository`; verify state transitions: idle → recording → transcribing → idle (on empty transcript), idle → recording → transcribing → playing → recording (happy path)
- **Unit — silence detection:** Feed synthetic amplitude stream; assert stop fires after 1500ms below threshold, not before
- **Unit — streaming TTS sentence extraction:** Feed streaming content strings; assert correct sentences extracted, markdown stripped
- **Widget — `VoiceMicButton`:** Verify correct icon/colour/animation for each `VoiceMode` value
- **Integration — permission flow:** Mock `permission_handler`; assert denied path shows snackbar and stays idle
---
## Out of scope
- Wake word / always-on listening
- On-device STT or TTS
- Voice settings UI in the Android app (voice and TTS settings managed via the web Settings page)
- iOS support (Android only per project constraints)
@@ -0,0 +1,199 @@
# Android Calendar Screen Design
**Goal:** Build the Android Calendar screen — a month-strip + daily agenda view with full event CRUD (create, edit, delete) including a simple recurrence picker.
**Architecture:** `CalendarNotifier` (`AsyncNotifier<CalendarState>`) holds a `Map<DateTime, List<CalendarEvent>>` keyed by date-only values, selected day, focused month, and loaded date range. The screen uses `table_calendar` for the month strip and a `ListView` for the daily agenda. Create/edit/delete is handled by `EventFormSheet`, a scrollable modal bottom sheet. The existing `/api/events` backend is used unchanged.
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, `table_calendar` package
---
## Files
### New
| File | Responsibility |
|------|---------------|
| `lib/data/models/calendar_event.dart` | `CalendarEvent` model + `fromJson` |
| `lib/data/api/events_api.dart` | `getEvents`, `createEvent`, `updateEvent`, `deleteEvent` Dio calls |
| `lib/providers/calendar_provider.dart` | `CalendarState`, `CalendarNotifier`, `calendarProvider` |
| `lib/screens/calendar/calendar_screen.dart` | Calendar screen UI (month strip + agenda) |
| `lib/screens/calendar/event_form_sheet.dart` | Create/edit modal bottom sheet |
### Modified
| File | Change |
|------|--------|
| `lib/providers/api_client_provider.dart` | Add `eventsApiProvider` |
| `lib/app.dart` | Replace Calendar stub route with `CalendarScreen()` |
| `test/widget_test.dart` | Add `CalendarEvent.fromJson` tests |
| `pubspec.yaml` | Add `table_calendar` dependency |
---
## Data Model
### `CalendarEvent`
```dart
class CalendarEvent {
final int id;
final String title;
final DateTime startDt;
final DateTime? endDt;
final bool allDay;
final String description;
final String location;
final String color;
final String? recurrence; // raw RRULE string, e.g. "FREQ=WEEKLY"
final int? projectId;
final int? reminderMinutes;
}
```
`fromJson` maps: `id`, `title`, `start_dt` / `end_dt` (ISO string → `DateTime.parse`), `all_day`, `description`, `location`, `color`, `recurrence` (nullable), `project_id` (nullable), `reminder_minutes` (nullable).
Date normalization helper — used throughout to build map keys:
```dart
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
```
---
## API Layer
### `events_api.dart`
```dart
class EventsApi {
final Dio _dio;
const EventsApi(this._dio);
// GET /api/events?from=<iso>&to=<iso>
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async { ... }
// POST /api/events
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async { ... }
// PATCH /api/events/{id}
Future<CalendarEvent> updateEvent(int id, Map<String, dynamic> fields) async { ... }
// DELETE /api/events/{id}
Future<void> deleteEvent(int id) async { ... }
}
```
All methods catch `DioException` and rethrow via `dioToApp(e)` (same pattern as `NewsApi`).
### `api_client_provider.dart` addition
```dart
final eventsApiProvider = Provider<EventsApi>((ref) =>
EventsApi(ref.watch(dioProvider)));
```
---
## State Management
### `CalendarState`
```dart
class CalendarState {
final Map<DateTime, List<CalendarEvent>> eventsByDay; // keys: midnight local time
final DateTime selectedDay;
final DateTime focusedMonth;
final DateTimeRange loadedRange;
}
```
### `CalendarNotifier extends AsyncNotifier<CalendarState>`
- **`build()`**: fetches events for `[firstDayOfMonth - 1 month, lastDayOfMonth + 1 month]` for the current month; populates `eventsByDay`; sets `selectedDay` to today; sets `loadedRange` to the fetched range.
- **`selectDay(DateTime day)`**: synchronous state update — updates `selectedDay` and `focusedMonth` to `DateTime(day.year, day.month)`. No API call.
- **`loadMonth(DateTime month)`**: updates `focusedMonth`. If `month` is already within `loadedRange`, no-op (state update only). Otherwise fetches events for that month, merges new items into `eventsByDay`, extends `loadedRange`.
- **`addEvent(CalendarEvent event)`**: inserts the event into `eventsByDay` under `dateOnly(event.startDt)`. Synchronous local mutation after successful API call.
- **`updateEvent(CalendarEvent updated)`**: removes old entry by `id` from its old date bucket (scanned), inserts under `dateOnly(updated.startDt)`. Synchronous local mutation.
- **`removeEvent(int id, DateTime date)`**: removes from `eventsByDay[dateOnly(date)]` by id. Synchronous local mutation.
All three mutation methods accept the server-returned `CalendarEvent` — the screen calls the API first, then passes the result to the notifier.
---
## Screen Behaviour
### `CalendarScreen` (`ConsumerStatefulWidget`)
**AppBar**: title "Calendar".
**Month strip** (`TableCalendar`):
- Format: `CalendarFormat.month`
- Selected day highlighted with primary color
- Days with events show a dot indicator
- `onDaySelected`: calls `notifier.selectDay(day)`
- `onPageChanged`: calls `notifier.loadMonth(month)`
**Agenda list** (`ListView.builder`):
- Items: `state.eventsByDay[dateOnly(state.selectedDay)] ?? []`
- Each `EventTile` shows: color dot, title, time string ("All day" if `allDay`, otherwise formatted start time)
- Tap → opens `EventFormSheet` in edit mode
- Empty: centered "No events" message
**FAB** (`FloatingActionButton`): opens `EventFormSheet` in create mode with `startDt` pre-set to `selectedDay` at current time (rounded to nearest hour)
**Initial loading**: `CircularProgressIndicator` centered. Error: message + "Retry" button calls `ref.invalidate(calendarProvider)`.
---
## Event Form Sheet
### `EventFormSheet` (shown via `showModalBottomSheet(isScrollControlled: true, useSafeArea: true)`)
Accepts `CalendarEvent? event` (null = create mode) and `DateTime? initialDate` (used in create mode).
**Fields:**
| Field | Widget | Notes |
|-------|--------|-------|
| Title | `TextField` | Required |
| All-day | `SwitchListTile` | Hides time pickers when on |
| Start date | `ListTile``showDatePicker` | |
| Start time | `ListTile``showTimePicker` | Hidden when all-day |
| End date | `ListTile``showDatePicker` | Optional, clearable |
| End time | `ListTile``showTimePicker` | Hidden when all-day |
| Repeat | `DropdownButton` | None / Daily / Weekly / Monthly / Yearly |
| Description | `TextField` multiline | Optional |
| Location | `TextField` | Optional |
| Color | Row of `InkWell` color chips | 6 preset colors + clear (empty string) |
**Repeat → RRULE mapping:**
| UI value | RRULE stored |
|----------|-------------|
| None | `null` |
| Daily | `FREQ=DAILY` |
| Weekly | `FREQ=WEEKLY` |
| Monthly | `FREQ=MONTHLY` |
| Yearly | `FREQ=YEARLY` |
Existing events with an unrecognized RRULE string (does not match the 5 patterns above) display "Custom (read-only)" and the dropdown is disabled. The raw RRULE is preserved unchanged on save.
**Save flow:**
1. Validate title non-empty
2. Build payload map from form state
3. Call `EventsApi.createEvent` or `EventsApi.updateEvent`
4. On success: call `notifier.addEvent(result)` or `notifier.updateEvent(result)`, pop sheet
5. On error: show SnackBar "Failed to save event."
**Delete flow (edit mode only):**
1. Show `AlertDialog` "Delete this event?"
2. On confirm: call `EventsApi.deleteEvent(event.id)`
3. On success: call `notifier.removeEvent(event.id, event.startDt)`, pop sheet
4. On error: show SnackBar "Failed to delete event."
---
## What This Does NOT Include
- Recurrence instance editing ("edit this event only" vs "all events") — backend does not support this distinction
- Reminder/notification editing — `reminder_minutes` field not exposed (backend stores it but no push notification is sent from events)
- Project association — `project_id` field not exposed in the form
- CalDAV sync trigger — available via `POST /api/events/sync` but not surfaced in this screen
@@ -0,0 +1,136 @@
# Android Nav Restructure & Projects Staleness Fix
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Projects bottom-nav tab with a "More" bottom sheet that houses Projects, News, and Calendar; fix stale task data on the project tasks screen.
**Architecture:** The shell's 4th nav item becomes a non-routing action — tapping it shows a `showModalBottomSheet` with the three overflow destinations. `_tabIndex()` maps `/projects`, `/news`, `/calendar` prefixes to index 3 so the "More" tab highlights correctly. News and Calendar are added as stub push-routes now; their real screens are built in subsequent passes. The staleness fix is a single `await` + `ref.invalidate` after returning from task edit.
**Tech Stack:** Flutter, GoRouter, Riverpod, Material 3 `NavigationBar` / `NavigationRail`
---
## Scope
Two independent changes in one pass:
1. **Nav restructure**`lib/app.dart`
2. **Staleness fix**`lib/screens/library/project_tasks_screen.dart`
---
## Design Details
### 1. Nav Restructure (`lib/app.dart`)
**Route changes:**
- Remove `/projects` from the `ShellRoute` routes list.
- Add three new top-level `GoRoute` entries (alongside the existing non-shell routes):
- `/projects``ProjectsScreen()` (moved from shell)
- `/news` → stub `Scaffold(body: Center(child: Text('News — coming soon')))`
- `/calendar` → stub `Scaffold(body: Center(child: Text('Calendar — coming soon')))`
- Add route constants to `lib/core/constants.dart`: `news = '/news'`, `calendar = '/calendar'`
**Shell tab list:**
```dart
static const _tabs = [
Routes.briefing,
Routes.knowledge,
Routes.conversations,
];
```
(3 entries — "More" is index 3 but handled specially, not a route)
**`_tabIndex()` update:**
```dart
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
}
// Projects, News, Calendar all highlight "More"
if (location.startsWith(Routes.projects) ||
location.startsWith(Routes.news) ||
location.startsWith(Routes.calendar)) return 3;
return 0;
}
```
**`onDestinationSelected` update (both `NavigationBar` and `NavigationRail`):**
```dart
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
```
**`_showMoreSheet` method on `_ShellState`:**
```dart
void _showMoreSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
context.push(Routes.projects);
},
),
ListTile(
leading: const Icon(Icons.newspaper_outlined),
title: const Text('News'),
onTap: () {
Navigator.pop(context);
context.push(Routes.news);
},
),
ListTile(
leading: const Icon(Icons.calendar_month_outlined),
title: const Text('Calendar'),
onTap: () {
Navigator.pop(context);
context.push(Routes.calendar);
},
),
],
),
),
);
}
```
**4th nav destination label:** "More" with `Icons.more_horiz_outlined` / `Icons.more_horiz`.
**NavigationRail note:** The wide-layout rail also gets the same 4 destinations and the same intercept on `onDestinationSelected`.
---
### 2. Projects Staleness Fix (`lib/screens/library/project_tasks_screen.dart`)
**Current (line ~321):**
```dart
context.push(Routes.taskEdit.replaceFirst(':id', '${task.id}'));
```
**Fixed:**
```dart
await context.push(Routes.taskEdit.replaceFirst(':id', '${task.id}'));
ref.invalidate(projectTasksProvider(widget.projectId));
```
This re-fetches tasks as soon as the user pops back from the task edit screen, eliminating stale data.
---
## What This Does NOT Include
- Real News screen implementation (separate spec/pass)
- Real Calendar screen implementation (separate spec/pass)
- Any changes to the web frontend
@@ -0,0 +1,177 @@
# Android News Screen Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the Android News screen — a paginated, filterable list of RSS news items with reactions and a Discuss action that opens a new general chat conversation.
**Architecture:** A `NewsNotifier` (`AsyncNotifier`) holds the full accumulated item list, pagination state, and selected feed ID. The screen is a `ConsumerStatefulWidget` accessed via the More bottom sheet at `/news`. Discuss uses `POST /api/chat/from-article/{id}` (same endpoint as the web) so any backend behaviour change is automatically reflected. Reactions reuse the existing `briefingApiProvider` methods. The existing `NewsCard` widget is used without modification.
**Tech Stack:** Flutter, Riverpod AsyncNotifier, Dio, GoRouter, existing `NewsCard` widget
---
## Files
### New
| File | Responsibility |
|------|---------------|
| `lib/data/models/news_item.dart` | `NewsItem` model + `fromJson` |
| `lib/data/models/briefing_feed.dart` | `BriefingFeed` model + `fromJson` |
| `lib/data/api/news_api.dart` | `getNewsItems(...)` and `getFeeds()` API calls |
| `lib/providers/news_provider.dart` | `NewsNotifier`, `NewsState`, `newsProvider`, `feedsProvider` |
| `lib/screens/news/news_screen.dart` | News screen UI |
### Modified
| File | Change |
|------|--------|
| `lib/data/api/chat_api.dart` | Add `openArticleInChat(int itemId) → Future<int>` |
| `lib/providers/api_client_provider.dart` | Add `newsApiProvider` |
---
## Data Models
### `NewsItem`
```dart
class NewsItem {
final int id;
final String title;
final String url;
final String snippet;
final String source;
final DateTime? publishedAt;
final List<String> topics;
final String? reaction; // 'up' | 'down' | null
}
```
`fromJson` maps: `id`, `title`, `url`, `snippet`, `source`, `published_at` (nullable ISO string → `DateTime.tryParse`), `topics` (cast `List<dynamic>``List<String>`), `reaction` (nullable string).
### `BriefingFeed`
```dart
class BriefingFeed {
final int id;
final String title;
final String url;
final String? category;
}
```
---
## API Layer
### `news_api.dart`
```dart
class NewsApi {
final Dio _dio;
const NewsApi(this._dio);
// GET /api/briefing/news
Future<NewsItemsResponse> getNewsItems({
int days = 90,
int limit = 40,
int offset = 0,
int? feedId,
}) async { ... }
// GET /api/briefing/feeds
Future<List<BriefingFeed>> getFeeds() async { ... }
}
class NewsItemsResponse {
final List<NewsItem> items;
final int offset;
final int limit;
}
```
### `chat_api.dart` addition
```dart
// POST /api/chat/from-article/{itemId}
// Returns conversation_id
Future<int> openArticleInChat(int itemId) async { ... }
```
### `api_client_provider.dart` addition
```dart
final newsApiProvider = Provider<NewsApi>((ref) =>
NewsApi(ref.watch(dioProvider)));
```
---
## State Management
### `NewsState`
```dart
class NewsState {
final List<NewsItem> items;
final int offset;
final bool hasMore;
final bool loadingMore;
final int? selectedFeedId;
final Map<int, String?> reactions; // item id → 'up'|'down'|null
}
```
### `NewsNotifier extends AsyncNotifier<NewsState>`
- `build()`: fetches first page (offset=0, no feed filter); initialises `reactions` from `item.reaction` on each item
- `loadMore()`: appends next page; no-op if `loadingMore` or `!hasMore`; sets `loadingMore = true` optimistically; on error shows snackbar (error returned to caller, not thrown into AsyncError)
- `setFeed(int? feedId)`: resets `items`, `offset`, `hasMore`, `reactions`; sets `selectedFeedId`; triggers `build()`-equivalent reload via `state = AsyncLoading()` + fetch
- `toggleReaction(int itemId, String reaction)`: optimistic toggle in `reactions` map; calls `briefingApi.postRssReaction` or `deleteRssReaction`; reverts on error
### `feedsProvider extends AsyncNotifier<List<BriefingFeed>>`
- `build()`: fetches once; cached for the session (no invalidation needed — feeds rarely change)
---
## Screen Behaviour
### `NewsScreen` (`ConsumerStatefulWidget`)
**AppBar**: title "News", subtitle "Last 90 days"
**Feed filter row** (below app bar, above list): `DropdownButton` with "All feeds" option + one entry per feed. On change calls `ref.read(newsProvider.notifier).setFeed(id)`.
**List**: `ListView.builder` of `NewsCard` widgets. Each `NewsCard` receives:
- `item`: `RssItemMeta.fromNewsItem(item)` — a thin adapter since `NewsCard` already uses `RssItemMeta`
- `reaction`: `newsState.reactions[item.id]`
- `onReaction`: calls `notifier.toggleReaction`
- `onDiscuss`: calls `_handleDiscuss(item.id)`
**Load more**: `ListTile` / `FilledButton.tonal` at the bottom — shows "Load more" when `hasMore && !loadingMore`, spinner when `loadingMore`, hidden when `!hasMore`.
**Initial loading**: `CircularProgressIndicator` centered. Error state shows message + "Retry" button that calls `ref.invalidate(newsProvider)`.
**Discuss flow** (`_handleDiscuss`):
1. Track `_openingChat = {itemId}` in local `setState` (disables that card's button while in flight)
2. Call `chatApi.openArticleInChat(itemId)`
3. On success: `context.push(Routes.chat.replaceFirst(':id', '$conversationId'))`
4. On error: show snackbar "Failed to open article in chat."
5. Always: remove from `_openingChat`
---
## `RssItemMeta` Adapter
`NewsCard` currently consumes `RssItemMeta` (from `news_card.dart`). Add a factory on `RssItemMeta`:
```dart
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
id: item.id,
title: item.title,
url: item.url,
source: item.source,
snippet: item.snippet,
publishedAt: item.publishedAt,
);
```
This keeps `NewsCard` unchanged and avoids coupling the widget to a second model type.
---
## What This Does NOT Include
- Feed management (add/remove/refresh feeds) — that is a Settings concern
- Offline caching
- Pull-to-refresh (load-more button is sufficient for the initial pass)
+276 -120
View File
@@ -1,32 +1,42 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'core/constants.dart';
import 'core/exceptions.dart';
import 'core/theme.dart';
import 'data/repositories/write_queue.dart';
import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart';
import 'core/exceptions.dart';
import 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart';
import 'providers/notes_provider.dart';
import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart';
import 'providers/journal_provider.dart';
import 'providers/knowledge_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/update_provider.dart';
import 'providers/tasks_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/briefing/briefing_screen.dart';
import 'screens/journal/journal_screen.dart';
import 'screens/knowledge/knowledge_screen.dart';
import 'screens/library/project_tasks_screen.dart';
import 'screens/chat/chat_screen.dart';
import 'screens/chat/conversations_tab_screen.dart';
import 'screens/library/library_screen.dart';
import 'screens/notes/note_detail_screen.dart';
import 'screens/projects/project_edit_screen.dart';
import 'screens/projects/projects_screen.dart';
import 'screens/notes/note_edit_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart';
import 'screens/calendar/calendar_screen.dart';
import 'providers/voice_provider.dart';
import 'widgets/offline_banner.dart';
import 'widgets/voice_mic_button.dart';
// ChangeNotifier that fires when auth or server URL changes,
// used as GoRouter.refreshListenable so the router re-evaluates redirects
@@ -35,6 +45,7 @@ class _RouterNotifier extends ChangeNotifier {
_RouterNotifier(Ref ref) {
ref.listen(authProvider, (_, _) => notifyListeners());
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
}
}
@@ -48,6 +59,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final location = state.matchedLocation;
final serverUrl = ref.read(serverUrlProvider);
final authStatus = ref.read(authProvider);
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
if (serverUrl == null || serverUrl.isEmpty) {
if (location != Routes.setup) return Routes.setup;
@@ -61,6 +73,16 @@ final routerProvider = Provider<GoRouter>((ref) {
return null;
}
// Offline and never logged in on this device — can't prove identity,
// so gate behind login. Offline + ever-logged-in falls through to
// normal navigation with the offline banner surfacing in _Shell.
if (authStatus == AuthStatus.offline && !hasEverLoggedIn) {
if (location != Routes.login && location != Routes.setup) {
return Routes.login;
}
return null;
}
return null;
},
routes: [
@@ -82,7 +104,10 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: Routes.noteNew,
builder: (_, _) => const NoteEditScreen(),
builder: (_, state) {
final extra = state.extra as Map<String, dynamic>?;
return NoteEditScreen(noteType: extra?['noteType'] as String?);
},
),
GoRoute(
path: Routes.noteDetail,
@@ -116,6 +141,16 @@ final routerProvider = Provider<GoRouter>((ref) {
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: '/projects/new',
builder: (_, _) => const ProjectEditScreen(),
),
GoRoute(
path: Routes.projectEdit,
builder: (_, state) => ProjectEditScreen(
projectId: int.parse(state.pathParameters['id']!),
),
),
GoRoute(
path: Routes.chat,
builder: (_, state) => ChatScreen(
@@ -126,17 +161,25 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
path: Routes.journal,
builder: (_, _) => const JournalScreen(),
),
GoRoute(
path: Routes.library,
builder: (_, _) => const LibraryScreen(),
path: Routes.knowledge,
builder: (_, _) => const KnowledgeScreen(),
),
GoRoute(
path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(),
),
GoRoute(
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
),
],
),
],
@@ -151,17 +194,31 @@ class _Shell extends ConsumerStatefulWidget {
ConsumerState<_Shell> createState() => _ShellState();
}
class _ShellState extends ConsumerState<_Shell> {
static const _tabs = [
Routes.briefing,
Routes.library,
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _baseTabs = [
Routes.journal,
Routes.knowledge,
Routes.conversations,
Routes.projects,
];
List<String> _tabs() => [
..._baseTabs,
Routes.calendar,
];
// Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh;
int? _prevTabIndex;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clean up any leftover APKs from previous update cycles.
ref.read(updateProvider.notifier).cleanup();
// Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) {
@@ -170,11 +227,53 @@ class _ShellState extends ConsumerState<_Shell> {
ref.read(updateProvider.notifier).check(repoUrl);
}
}
// Sync device timezone to backend so briefing and chat use local time.
// Sync device timezone to backend so journal and chat use local time.
_syncTimezone();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) return;
final now = DateTime.now();
if (_lastResumeRefresh != null &&
now.difference(_lastResumeRefresh!) < _resumeCooldown) {
return;
}
_lastResumeRefresh = now;
_refreshAll();
}
/// Refresh every major data provider. Safe to call speculatively —
/// providers that aren't currently watched are already disposed.
void _refreshAll() {
ref.read(conversationsProvider.notifier).refresh();
ref.read(calendarProvider.notifier).refresh();
ref.read(knowledgeProvider.notifier).refresh();
// journalProvider is an AsyncNotifier; invalidating is safe even if
// the journal screen isn't currently mounted.
ref.invalidate(journalProvider);
}
/// Refresh only the provider backing the given shell tab route.
void _refreshTab(String route) {
if (route == Routes.journal) {
ref.invalidate(journalProvider);
} else if (route == Routes.knowledge) {
ref.read(knowledgeProvider.notifier).refresh();
} else if (route == Routes.conversations) {
ref.read(conversationsProvider.notifier).refresh();
} else if (route == Routes.calendar) {
ref.read(calendarProvider.notifier).refresh();
}
}
Future<void> _syncTimezone() async {
try {
final tzInfo = await FlutterTimezone.getLocalTimezone();
@@ -184,73 +283,62 @@ class _ShellState extends ConsumerState<_Shell> {
}
}
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
int _tabIndex(String location, List<String> tabs) {
for (var i = 0; i < tabs.length; i++) {
if (location.startsWith(tabs[i])) return i;
}
return 0;
}
void _showUpdateDialog(UpdateState update) {
showDialog<void>(
void _showMoreSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (dialogContext) => Consumer(
builder: (context, ref, _) {
final state = ref.watch(updateProvider);
final isDownloading = state.status == UpdateStatus.downloading;
return AlertDialog(
title: const Text('Update available'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
if (state.currentVersion != null)
Text(
'Installed: v${state.currentVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
if (isDownloading) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: state.downloadProgress > 0
? state.downloadProgress
: null,
),
const SizedBox(height: 4),
Text(
'Downloading… '
'${(state.downloadProgress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
if (state.status == UpdateStatus.error &&
state.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
state.errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
],
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.folder),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
context.push(Routes.projects);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Later'),
),
if (!isDownloading && state.downloadUrl != null)
FilledButton(
onPressed: () => ref
.read(updateProvider.notifier)
.downloadAndInstall(),
child: const Text('Download & Install'),
),
],
);
},
ListTile(
leading: const Icon(LucideIcons.calendar),
title: const Text('Calendar'),
onTap: () {
Navigator.pop(context);
context.push(Routes.calendar);
},
),
],
),
),
);
}
void _showUpdateSnackbar(UpdateState update) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('v${update.latestVersion} ready to install'),
duration: const Duration(seconds: 6),
action: SnackBarAction(
label: 'Install',
onPressed: () => ref.read(updateProvider.notifier).install(),
),
),
);
}
void _showQueueFailureSnackbar(QueueFailure failure) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure.message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 6),
),
);
}
@@ -259,14 +347,40 @@ class _ShellState extends ConsumerState<_Shell> {
Widget build(BuildContext context) {
// Show update dialog once when a new version is detected.
ref.listen(updateProvider, (prev, next) {
if (next.status == UpdateStatus.available &&
prev?.status != UpdateStatus.available) {
if (next.status == UpdateStatus.readyToInstall &&
prev?.status != UpdateStatus.readyToInstall) {
WidgetsBinding.instance
.addPostFrameCallback((_) => _showUpdateDialog(next));
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
}
});
// Phase 3 — drain the offline write queue when we transition into
// an online state. Fires for unknown→authenticated (cold start),
// offline→authenticated (came back), and unauthenticated→authenticated
// (login). Idempotent if the queue is empty.
ref.listen(authProvider, (prev, next) {
if (next == AuthStatus.authenticated &&
prev != AuthStatus.authenticated) {
ref.read(writeQueueProvider).replay();
}
});
// Phase 3 — surface queue failures (overwrites, missing targets, 4xx
// rejections) so the user knows their offline edit didn't land.
ref.listen(writeQueueFailuresProvider, (_, next) {
next.whenData(_showQueueFailureSnackbar);
});
final tabs = _tabs();
final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location);
final index = _tabIndex(location, tabs);
// Refresh the incoming tab's data when switching between shell tabs.
if (_prevTabIndex != null && _prevTabIndex != index) {
final route = index < tabs.length ? tabs[index] : tabs[0];
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
}
_prevTabIndex = index;
final child = widget.child;
final isWide = MediaQuery.of(context).size.width >= 600;
@@ -275,30 +389,41 @@ class _ShellState extends ConsumerState<_Shell> {
body: SafeArea(
child: Column(
children: [
const OfflineBanner(),
const _QuickCaptureBar(),
Expanded(
child: Row(
children: [
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
onDestinationSelected: (i) => context.go(tabs[i]),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
icon: Icon(LucideIcons.bookOpen),
selectedIcon: Icon(LucideIcons.bookOpen),
label: Text('Journal'),
),
NavigationRailDestination(
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: Text('Library'),
icon: Icon(LucideIcons.lightbulb),
selectedIcon: Icon(LucideIcons.lightbulb),
label: Text('Knowledge'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
icon: Icon(LucideIcons.messageCircle),
selectedIcon: Icon(LucideIcons.messageCircle),
label: Text('Chat'),
),
NavigationRailDestination(
icon: Icon(LucideIcons.folder),
selectedIcon: Icon(LucideIcons.folder),
label: Text('Projects'),
),
NavigationRailDestination(
icon: Icon(LucideIcons.calendar),
selectedIcon: Icon(LucideIcons.calendar),
label: Text('Calendar'),
),
],
),
const VerticalDivider(width: 1),
@@ -315,6 +440,7 @@ class _ShellState extends ConsumerState<_Shell> {
return Scaffold(
body: Column(
children: [
const OfflineBanner(),
const _QuickCaptureBar(),
Expanded(
child: MediaQuery.removePadding(
@@ -326,24 +452,35 @@ class _ShellState extends ConsumerState<_Shell> {
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
selectedIndex: index >= 3 ? 3 : index,
onDestinationSelected: (i) {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(tabs[i]);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
icon: Icon(LucideIcons.bookOpen),
selectedIcon: Icon(LucideIcons.bookOpen),
label: 'Journal',
),
NavigationDestination(
icon: Icon(Icons.library_books_outlined),
selectedIcon: Icon(Icons.library_books),
label: 'Library',
icon: Icon(LucideIcons.lightbulb),
selectedIcon: Icon(LucideIcons.lightbulb),
label: 'Knowledge',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
icon: Icon(LucideIcons.messageCircle),
selectedIcon: Icon(LucideIcons.messageCircle),
label: 'Chat',
),
NavigationDestination(
icon: Icon(LucideIcons.moreHorizontal),
selectedIcon: Icon(LucideIcons.moreHorizontal),
label: 'More',
),
],
),
);
@@ -384,35 +521,46 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) {
if (!mounted) break;
try {
final result = await api.capture(text);
// Dequeue before the mounted check — SharedPreferences doesn't need
// the widget alive, and skipping this would leave a ghost item.
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
await ref.read(captureQueueProvider.notifier).dequeue(text);
if (!mounted) break;
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
} on NetworkException {
break;
} catch (_) {
// Server error or unexpected failure — drop from queue to prevent
// ghost items that can never be cleared.
// Server error — drop from queue to prevent ghost items.
await ref.read(captureQueueProvider.notifier).dequeue(text);
}
}
}
Future<void> _toggleCaptureMic() async {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
ref.read(voiceProvider.notifier).exitVoiceMode();
return;
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
ref.read(captureWorkQueueProvider.notifier).enqueue(transcript);
},
enableTts: false,
onError: (msg) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
},
);
}
String _hintForLocation(String location) {
if (location.startsWith(Routes.library) &&
location.contains('tasks')) { return 'Add a task'; }
if (location.startsWith(Routes.knowledge)) return 'Capture a note…';
if (location.startsWith(Routes.projects)) return 'Capture a note';
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
return 'Capture a note…';
}
@@ -452,14 +600,16 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
onSubmitted: (_) => _submit(),
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: _hintForLocation(location),
hintText: ref.watch(voiceProvider).voiceModeActive
? 'Listening…'
: _hintForLocation(location),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
prefixIcon: totalPending > 0
? Badge(
label: Text('$totalPending'),
child: const Icon(Icons.cloud_upload_outlined),
child: const Icon(LucideIcons.uploadCloud),
)
: isWorking
? const Padding(
@@ -471,10 +621,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
strokeWidth: 2),
),
)
: const Icon(Icons.auto_awesome_outlined),
: const Icon(LucideIcons.sparkles),
suffixIcon: _controller.text.trim().isNotEmpty
? IconButton(
icon: const Icon(Icons.send),
icon: const Icon(LucideIcons.send),
onPressed: _submit,
tooltip: 'Capture',
)
@@ -482,8 +632,14 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
),
),
),
VoiceMicButton(
mode: ref.watch(voiceProvider).mode,
voiceModeActive: ref.watch(voiceProvider).voiceModeActive,
amplitude: ref.watch(voiceProvider).amplitude,
onTap: _toggleCaptureMic,
),
IconButton(
icon: const Icon(Icons.settings_outlined),
icon: const Icon(LucideIcons.settings),
tooltip: 'Settings',
onPressed: () => context.push(Routes.settings),
),
+4 -2
View File
@@ -9,12 +9,14 @@ abstract class Routes {
static const tasks = '/tasks';
static const taskNew = '/tasks/new';
static const taskEdit = '/tasks/:id/edit';
static const knowledge = '/knowledge';
static const projects = '/projects';
static const projectEdit = '/projects/:id/edit';
static const conversations = '/chat';
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const library = '/library';
static const journal = '/journal';
static const calendar = '/calendar';
static const projectTasks = '/projects/:id/tasks';
}
+9
View File
@@ -17,3 +17,12 @@ class AuthException extends AppException {
class NotFoundException extends AppException {
const NotFoundException(super.message);
}
/// 4xx/5xx response received from the server (excluding 401/404 which have
/// dedicated subclasses). Distinguished from `NetworkException` (connection
/// failure) so the offline write queue can drop non-retryable failures
/// instead of looping forever on a 422.
class ServerException extends AppException {
final int statusCode;
const ServerException(super.message, this.statusCode);
}
+149 -35
View File
@@ -2,35 +2,142 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
// ── Colour constants ──────────────────────────────────────────────────────────
//
// Mirrors the web frontend's design system (`docs/design-system.md` in
// fabledscribe). Foundation pass shipped on web 2026-04-27 in `7a9a8b7`;
// this is the equivalent palette swap for Flutter. Per-screen "surface
// phase" reclassification (button Hybrid rule, border audit, etc.) is
// deferred — most widgets read `colorScheme.primary` so the palette flip
// alone covers a large surface.
const _darkBackground = Color(0xFF111113);
const _darkSurface = Color(0xFF18181C);
const _darkSurfaceVar = Color(0xFF1E1E24);
const _darkPrimary = Color(0xFF6366F1);
const _darkOnSurface = Color(0xFFE8E8F0);
const _darkOnSurfaceVar = Color(0xFF8888A8);
const _darkOutline = Color(0xFF2E2E3A);
// Dark mode — Obsidian / Iron / Pewter / Parchment (FabledSword baseline)
const _darkBackground = Color(0xFF14171A); // Obsidian
const _darkSurface = Color(0xFF1E2228); // Iron
const _darkSurfaceVar = Color(0xFF2C313A); // Slate
const _darkPrimary = Color(0xFF5B4A8A); // dusty violet (Scribe accent)
const _darkPrimaryDeep = Color(0xFF3F3560); // gradient stop
const _darkOnSurface = Color(0xFFE8E4D8); // Parchment
const _darkOnSurfaceVar = Color(0xFFC2BFB4); // Vellum
const _darkOutline = Color(0xFF3F4651); // Pewter
const _lightBackground = Color(0xFFF4F4F8);
const _lightSurface = Color(0xFFFFFFFF);
const _lightSurfaceVar = Color(0xFFF0F0F5);
const _lightPrimary = Color(0xFF4F46E5);
const _lightOnSurface = Color(0xFF18181C);
const _lightOnSurfaceVar = Color(0xFF6B6B88);
const _lightOutline = Color(0xFFD4D4E4);
// Light mode — warm parchment (Scribe iteration)
const _lightBackground = Color(0xFFF5F1E8); // warm cream page
const _lightSurface = Color(0xFFFBF8F0); // near-white card
const _lightSurfaceVar = Color(0xFFEFEAE0); // inset / hover surface
const _lightPrimary = Color(0xFF5B4A8A); // dusty violet (same on both modes)
const _lightOnSurface = Color(0xFF14171A); // deep ink (Obsidian inverted)
const _lightOnSurfaceVar = Color(0xFF5A5852); // warm mid grey
const _lightOutline = Color(0xFFD9D6CE); // warm light pewter
// Semantic — identical across themes
const _semanticError = Color(0xFFC04A1F); // terracotta — validation/error
const _semanticErrorBg = Color(0xFF7E2A1F); // Oxblood-hover for dark error container
const _semanticErrorFg = Color(0xFFFEE2E2); // light text on dark error container
// Action tokens — Hybrid rule per the doc. These don't fit ColorScheme
// natively (Material's primary/secondary/tertiary slots all carry the
// brand accent), so they live on a ThemeExtension exposed below.
const _actionPrimary = Color(0xFF4A5D3F); // Moss
const _actionPrimaryHover = Color(0xFF5A6F4D);
const _actionSecondary = Color(0xFF8B7355); // Bronze
const _actionSecondaryHover = Color(0xFFA0876A);
const _actionDestructive = Color(0xFF6B2118); // Oxblood
const _actionDestructiveHover = Color(0xFF7E2A1F);
const _actionGhostBorder = Color(0xFF3F4651); // Pewter (same as outline)
// ── Action ThemeExtension ─────────────────────────────────────────────────────
// Read with: Theme.of(context).extension<ActionColors>()!.primary
//
// Flutter's ColorScheme has primary/secondary/tertiary all conceptually
// "branded", whereas the doc's Hybrid rule reserves accent for brand
// moments (Send, empty-state CTAs) and routes action buttons through a
// separate Moss/Bronze/Oxblood/Pewter palette. This extension carries
// those tokens without polluting ColorScheme.
@immutable
class ActionColors extends ThemeExtension<ActionColors> {
final Color primary; // Moss — Save / Confirm
final Color primaryHover;
final Color secondary; // Bronze — Cancel / alternate paths
final Color secondaryHover;
final Color destructive; // Oxblood — Delete / irreversible
final Color destructiveHover;
final Color ghostBorder; // Pewter — tertiary / "later" / "skip"
const ActionColors({
required this.primary,
required this.primaryHover,
required this.secondary,
required this.secondaryHover,
required this.destructive,
required this.destructiveHover,
required this.ghostBorder,
});
static const _kStandard = ActionColors(
primary: _actionPrimary,
primaryHover: _actionPrimaryHover,
secondary: _actionSecondary,
secondaryHover: _actionSecondaryHover,
destructive: _actionDestructive,
destructiveHover: _actionDestructiveHover,
ghostBorder: _actionGhostBorder,
);
@override
ActionColors copyWith({
Color? primary,
Color? primaryHover,
Color? secondary,
Color? secondaryHover,
Color? destructive,
Color? destructiveHover,
Color? ghostBorder,
}) {
return ActionColors(
primary: primary ?? this.primary,
primaryHover: primaryHover ?? this.primaryHover,
secondary: secondary ?? this.secondary,
secondaryHover: secondaryHover ?? this.secondaryHover,
destructive: destructive ?? this.destructive,
destructiveHover: destructiveHover ?? this.destructiveHover,
ghostBorder: ghostBorder ?? this.ghostBorder,
);
}
@override
ActionColors lerp(ThemeExtension<ActionColors>? other, double t) {
if (other is! ActionColors) return this;
return ActionColors(
primary: Color.lerp(primary, other.primary, t)!,
primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!,
secondary: Color.lerp(secondary, other.secondary, t)!,
secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!,
destructive: Color.lerp(destructive, other.destructive, t)!,
destructiveHover: Color.lerp(destructiveHover, other.destructiveHover, t)!,
ghostBorder: Color.lerp(ghostBorder, other.ghostBorder, t)!,
);
}
}
// ── Typography ─────────────────────────────────────────────────────────────────
// Inter for body / labels / titleMedium-and-below.
// Fraunces for display / headline / titleLarge — only at ≥18px per the doc.
// JetBrains Mono available via GoogleFonts.jetBrainsMono() at call sites for
// code blocks (Flutter's TextTheme has no dedicated mono slot).
TextTheme _buildTextTheme(TextTheme base) {
final inter = GoogleFonts.interTextTheme(base);
final fraunces = GoogleFonts.frauncesTextTheme(base);
return base.copyWith(
// Headings / titles use Fraunces
return inter.copyWith(
displayLarge: fraunces.displayLarge,
displayMedium: fraunces.displayMedium,
displaySmall: fraunces.displaySmall,
headlineLarge: fraunces.headlineLarge,
headlineMedium: fraunces.headlineMedium,
headlineSmall: fraunces.headlineSmall,
titleLarge: fraunces.titleLarge,
titleMedium: fraunces.titleMedium,
// Body / labels remain system default
// titleMedium / titleSmall / body* / label* stay Inter
);
}
@@ -41,7 +148,7 @@ ThemeData fabledDarkTheme() {
brightness: Brightness.dark,
primary: _darkPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFF3730A3),
primaryContainer: _darkPrimaryDeep,
onPrimaryContainer: _darkOnSurface,
secondary: _darkPrimary,
onSecondary: Colors.white,
@@ -51,10 +158,10 @@ ThemeData fabledDarkTheme() {
onTertiary: Colors.white,
tertiaryContainer: _darkSurfaceVar,
onTertiaryContainer: _darkOnSurface,
error: const Color(0xFFEF4444),
error: _semanticError,
onError: Colors.white,
errorContainer: const Color(0xFF7F1D1D),
onErrorContainer: const Color(0xFFFEE2E2),
errorContainer: _semanticErrorBg,
onErrorContainer: _semanticErrorFg,
surface: _darkSurface,
onSurface: _darkOnSurface,
surfaceContainerHighest: _darkSurfaceVar,
@@ -73,6 +180,7 @@ ThemeData fabledDarkTheme() {
colorScheme: cs,
scaffoldBackgroundColor: _darkBackground,
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
extensions: const [ActionColors._kStandard],
cardTheme: CardThemeData(
color: _darkSurface,
elevation: 2,
@@ -91,15 +199,15 @@ ThemeData fabledDarkTheme() {
filled: true,
fillColor: _darkSurfaceVar,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkOutline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkOutline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkPrimary, width: 2),
),
),
@@ -118,7 +226,9 @@ ThemeData fabledLightTheme() {
brightness: Brightness.light,
primary: _lightPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFFE0E0FF),
// Warm parchment-tinted primary container, replacing the prior cool
// indigo `#EDE5FF`. Used for chip/badge backgrounds at low alpha.
primaryContainer: const Color(0xFFEDE9F4),
onPrimaryContainer: _lightOnSurface,
secondary: _lightPrimary,
onSecondary: Colors.white,
@@ -128,10 +238,10 @@ ThemeData fabledLightTheme() {
onTertiary: Colors.white,
tertiaryContainer: _lightSurfaceVar,
onTertiaryContainer: _lightOnSurface,
error: const Color(0xFFDC2626),
error: _semanticError,
onError: Colors.white,
errorContainer: const Color(0xFFFEE2E2),
onErrorContainer: const Color(0xFF7F1D1D),
onErrorContainer: _semanticErrorBg,
surface: _lightSurface,
onSurface: _lightOnSurface,
surfaceContainerHighest: _lightSurfaceVar,
@@ -150,6 +260,7 @@ ThemeData fabledLightTheme() {
colorScheme: cs,
scaffoldBackgroundColor: _lightBackground,
textTheme: _buildTextTheme(ThemeData.light().textTheme),
extensions: const [ActionColors._kStandard],
cardTheme: CardThemeData(
color: _lightSurface,
elevation: 1,
@@ -168,15 +279,15 @@ ThemeData fabledLightTheme() {
filled: true,
fillColor: _lightSurfaceVar,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightOutline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightOutline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightPrimary, width: 2),
),
),
@@ -191,7 +302,10 @@ ThemeData fabledLightTheme() {
}
// ── GradientButton ─────────────────────────────────────────────────────────────
// Use wherever the web app uses the indigo gradient button (send, primary actions).
// Brand-moment CTA equivalent of the web's `--gradient-cta` — chat send,
// journal send, primary "Scribe-feature" actions. Reserve for those moments
// per the doc's Hybrid rule; use FilledButton with ActionColors.primary
// (Moss) for everything else.
class GradientButton extends StatelessWidget {
final VoidCallback? onPressed;
@@ -218,15 +332,15 @@ class GradientButton extends StatelessWidget {
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
colors: [_darkPrimary, _darkPrimaryDeep],
),
color: disabled ? const Color(0xFF6366F1) : null,
color: disabled ? _darkPrimary : null,
borderRadius: BorderRadius.circular(12),
boxShadow: disabled
? null
: [
BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.35),
color: _darkPrimary.withValues(alpha: 0.45),
blurRadius: 8,
offset: const Offset(0, 3),
),
+16
View File
@@ -63,5 +63,21 @@ AppException dioToApp(DioException e) {
final status = e.response?.statusCode;
if (status == 401) return const AuthException('Not authenticated.');
if (status == 404) return const NotFoundException('Resource not found.');
if (status != null && status >= 400) {
final msg = _extractServerErrorMessage(e.response) ??
'Request failed (HTTP $status).';
return ServerException(msg, status);
}
return NetworkException(e.message ?? 'Unknown network error.');
}
String? _extractServerErrorMessage(Response? resp) {
final data = resp?.data;
if (data is Map<String, dynamic>) {
for (final key in const ['detail', 'error', 'message']) {
final value = data[key];
if (value is String && value.isNotEmpty) return value;
}
}
return null;
}
-81
View File
@@ -1,81 +0,0 @@
import 'package:dio/dio.dart';
import '../models/briefing_conversation.dart';
import '../models/message.dart';
import 'api_client.dart';
class BriefingApi {
final Dio _dio;
const BriefingApi(this._dio);
/// GET /api/briefing/conversations/today
/// Returns (or creates) today's briefing conversation with messages embedded.
Future<BriefingConversation> getToday() async {
try {
final response = await _dio.get('/api/briefing/conversations/today');
return BriefingConversation.fromJson(
response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/conversations
/// Returns list of past briefing conversations (no messages embedded).
Future<List<BriefingConversation>> getHistory() async {
try {
final response = await _dio.get('/api/briefing/conversations');
final data = response.data as Map<String, dynamic>;
final list = data['conversations'] as List<dynamic>;
return list
.map((e) => BriefingConversation.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/briefing/conversations/`<id>`/messages
Future<List<Message>> getMessages(int convId) async {
try {
final response =
await _dio.get('/api/briefing/conversations/$convId/messages');
final data = response.data as Map<String, dynamic>;
final list = data['messages'] as List<dynamic>;
return list
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST /api/briefing/trigger body: {"slot": slot}
/// slot: "compilation" | "morning" | "midday" | "afternoon"
Future<void> triggerSlot(String slot) async {
try {
await _dio.post('/api/briefing/trigger', data: {'slot': slot});
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"}
Future<void> postRssReaction(int rssItemId, String reaction) async {
try {
await _dio.post('/api/briefing/rss-reactions',
data: {'rss_item_id': rssItemId, 'reaction': reaction});
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// DELETE /api/briefing/rss-reactions/{rssItemId}
Future<void> deleteRssReaction(int rssItemId) async {
try {
await _dio.delete('/api/briefing/rss-reactions/$rssItemId');
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+42 -5
View File
@@ -6,6 +6,27 @@ import '../models/conversation.dart';
import '../models/message.dart';
import 'api_client.dart';
sealed class ChatStreamEvent {}
class ChatTextChunk extends ChatStreamEvent {
final String text;
ChatTextChunk(this.text);
}
class ChatStatusUpdate extends ChatStreamEvent {
final String status; // empty string = clear status
ChatStatusUpdate(this.status);
}
/// A single tool call fired during generation. Mirrors the `tool_call` SSE
/// event emitted by `generation_task.py` and the `tool_calls` array persisted
/// on the assistant Message row — same shape either way so the UI can render
/// live chips during streaming and re-render them from storage after reload.
class ChatToolCall extends ChatStreamEvent {
final Map<String, dynamic> toolCall;
ChatToolCall(this.toolCall);
}
class ChatApi {
final Dio _dio;
const ChatApi(this._dio);
@@ -71,8 +92,8 @@ class ChatApi {
}
}
// Step 2: GET the SSE stream and yield text chunks.
Stream<String> streamGeneration(int conversationId) async* {
// Step 2: GET the SSE stream and yield typed events (text chunks + status updates).
Stream<ChatStreamEvent> streamGeneration(int conversationId) async* {
try {
final response = await _dio.get(
'/api/chat/conversations/$conversationId/generation/stream',
@@ -108,14 +129,29 @@ class ChatApi {
if (data == '[DONE]') return;
if (currentEvent == 'done' || currentEvent == 'error') return;
// Parse as JSON if possible, otherwise yield raw text.
if (currentEvent == 'chunk' || currentEvent.isEmpty) {
try {
final obj = json.decode(data) as Map<String, dynamic>;
final text = obj['text'] as String? ?? '';
if (text.isNotEmpty) yield text;
if (text.isNotEmpty) yield ChatTextChunk(text);
} catch (_) {
if (data.isNotEmpty) yield data;
if (data.isNotEmpty) yield ChatTextChunk(data);
}
} else if (currentEvent == 'status') {
try {
final obj = json.decode(data) as Map<String, dynamic>;
final status = obj['status'] as String? ?? '';
yield ChatStatusUpdate(status);
} catch (_) {
// Ignore malformed status events
}
} else if (currentEvent == 'tool_call') {
try {
final obj = json.decode(data) as Map<String, dynamic>;
final tc = obj['tool_call'];
if (tc is Map<String, dynamic>) yield ChatToolCall(tc);
} catch (_) {
// Ignore malformed tool_call events
}
}
} else if (line.isEmpty) {
@@ -127,4 +163,5 @@ class ChatApi {
throw dioToApp(e);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:dio/dio.dart';
import '../models/calendar_event.dart';
import 'api_client.dart';
class EventsApi {
final Dio _dio;
const EventsApi(this._dio);
/// GET /api/events?from={iso}&to={iso}
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
try {
final response = await _dio.get(
'/api/events',
queryParameters: {
'from': from.toUtc().toIso8601String(),
'to': to.toUtc().toIso8601String(),
},
);
final list = response.data as List<dynamic>;
return list
.map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST /api/events
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
try {
final response = await _dio.post('/api/events', data: payload);
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// PATCH /api/events/{id}
Future<CalendarEvent> updateEvent(
int id, Map<String, dynamic> fields) async {
try {
final response = await _dio.patch('/api/events/$id', data: fields);
return CalendarEvent.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// DELETE /api/events/{id}
Future<void> deleteEvent(int id) async {
try {
await _dio.delete('/api/events/$id');
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:dio/dio.dart';
import '../models/journal_day.dart';
import 'api_client.dart';
class JournalApi {
final Dio _dio;
const JournalApi(this._dio);
/// GET /api/journal/today
/// Creates today's journal conversation + daily prep on demand if absent,
/// then returns the day payload.
Future<JournalDay> getToday() async {
try {
final response = await _dio.get('/api/journal/today');
return JournalDay.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/journal/day/&lt;iso_date&gt;
Future<JournalDay> getDay(String isoDate) async {
try {
final response = await _dio.get('/api/journal/day/$isoDate');
return JournalDay.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// GET /api/journal/days — list of dates with journal content, newest first.
Future<List<String>> getDays() async {
try {
final response = await _dio.get('/api/journal/days');
final data = response.data as Map<String, dynamic>;
final list = data['days'] as List<dynamic>;
return list.map((e) => e as String).toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST /api/journal/trigger-prep — force-regenerate today's daily prep
/// (or a specific day if [isoDate] is given).
Future<void> triggerPrep({String? isoDate}) async {
try {
final body = <String, dynamic>{};
if (isoDate != null) body['date'] = isoDate;
await _dio.post('/api/journal/trigger-prep', data: body);
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:dio/dio.dart';
import '../models/knowledge_item.dart';
import 'api_client.dart';
class KnowledgeApi {
final Dio _dio;
const KnowledgeApi(this._dio);
/// Fetch a page of IDs. Returns (ids, total).
Future<(List<int>, int)> fetchIds({
String? noteType,
List<String> tags = const [],
String sort = 'modified',
String? q,
int limit = 50,
int offset = 0,
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'offset': offset,
'sort': sort,
if (noteType != null) 'type': noteType,
if (tags.isNotEmpty) 'tags': tags.join(','),
if (q != null && q.isNotEmpty) 'q': q,
};
final response =
await _dio.get('/api/knowledge/ids', queryParameters: params);
final data = response.data as Map<String, dynamic>;
final ids =
(data['ids'] as List<dynamic>).map((e) => e as int).toList();
final total = data['total'] as int;
return (ids, total);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// Batch-hydrate up to 100 IDs into full items.
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) async {
if (ids.isEmpty) return [];
try {
final response = await _dio.get(
'/api/knowledge/batch',
queryParameters: {'ids': ids.join(',')},
);
final data = response.data as Map<String, dynamic>;
return (data['items'] as List<dynamic>)
.map((e) => KnowledgeItem.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// Per-type counts for tab labels.
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) async {
try {
final params = <String, dynamic>{
if (tags.isNotEmpty) 'tags': tags.join(','),
};
final response = await _dio.get('/api/knowledge/counts',
queryParameters: params);
return (response.data as Map<String, dynamic>)
.map((k, v) => MapEntry(k, (v as num).toInt()));
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// All tags for the current type filter.
Future<List<String>> fetchTags({String? noteType}) async {
try {
final response = await _dio.get(
'/api/knowledge/tags',
queryParameters: {if (noteType != null) 'type': noteType},
);
return (response.data['tags'] as List<dynamic>)
.map((e) => e as String)
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+4
View File
@@ -32,12 +32,14 @@ class NotesApi {
String body, {
List<String> tags = const [],
int? projectId,
String noteType = 'note',
}) async {
try {
final response = await _dio.post('/api/notes', data: {
'title': title,
'body': body,
'tags': tags,
'note_type': noteType,
if (projectId != null) 'project_id': projectId,
});
return Note.fromJson(response.data as Map<String, dynamic>);
@@ -53,12 +55,14 @@ class NotesApi {
List<String> tags = const [],
int? projectId,
bool clearProject = false,
String noteType = 'note',
}) async {
try {
final response = await _dio.put('/api/notes/$id', data: {
'title': title,
'body': body,
'tags': tags,
'note_type': noteType,
if (clearProject) 'project_id': null else if (projectId != null) 'project_id': projectId,
});
return Note.fromJson(response.data as Map<String, dynamic>);
+12 -2
View File
@@ -7,11 +7,19 @@ class ProjectsApi {
final Dio _dio;
const ProjectsApi(this._dio);
Future<List<Project>> getAll({String? status}) async {
Future<List<Project>> getAll({
String? status,
String sort = 'updated_at',
String order = 'desc',
}) async {
try {
final response = await _dio.get(
'/api/projects',
queryParameters: status != null ? {'status': status} : null,
queryParameters: {
'sort': sort,
'order': order,
if (status != null) 'status': status,
},
);
final data = response.data as Map<String, dynamic>;
final list = data['projects'] as List<dynamic>;
@@ -37,10 +45,12 @@ class ProjectsApi {
String? description,
String? goal,
String? color,
String status = 'active',
}) async {
try {
final response = await _dio.post('/api/projects', data: {
'title': title,
'status': status,
if (description != null && description.isNotEmpty)
'description': description,
if (goal != null && goal.isNotEmpty) 'goal': goal,
+9
View File
@@ -10,4 +10,13 @@ class SettingsApi {
data: {'user_timezone': ianaTimezone},
);
}
Future<Map<String, dynamic>> getAll() async {
final response = await _dio.get<Map<String, dynamic>>('/api/settings');
return response.data ?? {};
}
Future<void> update(Map<String, String> updates) async {
await _dio.put<void>('/api/settings', data: updates);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'api_client.dart';
class VoiceStatus {
final bool enabled;
final bool stt;
final bool tts;
const VoiceStatus({
required this.enabled,
required this.stt,
required this.tts,
});
/// True when voice is enabled and at least STT is ready.
/// TTS is optional — voice mode works without it (STT-only).
bool get fullyAvailable => enabled && stt;
factory VoiceStatus.fromJson(Map<String, dynamic> json) => VoiceStatus(
enabled: json['enabled'] as bool? ?? false,
stt: json['stt'] as bool? ?? false,
tts: json['tts'] as bool? ?? false,
);
}
class VoiceApi {
final Dio _dio;
const VoiceApi(this._dio);
/// Check whether voice features are available on this server.
Future<VoiceStatus> checkStatus() async {
try {
final response = await _dio.get('/api/voice/status');
return VoiceStatus.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST audio bytes (WAV) and return the transcript string.
/// [context] is optional recent conversation text passed as initial_prompt
/// to Whisper, reducing mishearings of domain-specific words.
/// Returns empty string on empty or error response.
Future<String> transcribe(Uint8List audioBytes, {String? context}) async {
try {
final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes(
audioBytes,
filename: 'audio.wav',
contentType: DioMediaType('audio', 'wav'),
),
if (context != null && context.isNotEmpty) 'context': context,
};
final formData = FormData.fromMap(fields);
final response = await _dio.post(
'/api/voice/transcribe',
data: formData,
options: Options(
receiveTimeout: const Duration(seconds: 60),
contentType: 'multipart/form-data',
),
);
final data = response.data as Map<String, dynamic>;
return (data['transcript'] as String? ?? '').trim();
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// POST text and return raw WAV bytes.
Future<Uint8List> synthesise(String text) async {
try {
final response = await _dio.post(
'/api/voice/synthesise',
data: {'text': text},
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data as List<int>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
+760
View File
@@ -0,0 +1,760 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
import '../models/calendar_event.dart';
import '../models/conversation.dart';
import '../models/milestone.dart';
import '../models/note.dart';
import '../models/project.dart';
import '../models/task.dart';
part 'database.g.dart';
// ── Tables ───────────────────────────────────────────────────────────────────
//
// Each cached_* table mirrors the corresponding model 1:1. Tags / list fields
// are stored as JSON-encoded text; SQLite lacks a native list type and
// Drift's typed converters add ceremony we don't need for read-side caching.
// `cachedAt` tracks when the row was last written from a successful API
// response; the SyncMetadata table tracks the per-domain "last bulk fetch"
// timestamp used by the OfflineBanner.
class CachedNotes extends Table {
IntColumn get id => integer()();
TextColumn get title => text()();
TextColumn get body => text()();
TextColumn get tagsJson => text().withDefault(const Constant('[]'))();
TextColumn get noteType => text().withDefault(const Constant('note'))();
IntColumn get projectId => integer().nullable()();
IntColumn get milestoneId => integer().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get cachedAt =>
dateTime().clientDefault(() => DateTime.now())();
@override
Set<Column> get primaryKey => {id};
}
class CachedTasks extends Table {
IntColumn get id => integer()();
TextColumn get title => text()();
TextColumn get description => text().nullable()();
TextColumn get status => text()();
TextColumn get priority => text()();
DateTimeColumn get dueDate => dateTime().nullable()();
IntColumn get projectId => integer().nullable()();
IntColumn get milestoneId => integer().nullable()();
IntColumn get parentId => integer().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get cachedAt =>
dateTime().clientDefault(() => DateTime.now())();
@override
Set<Column> get primaryKey => {id};
}
class CachedProjects extends Table {
IntColumn get id => integer()();
TextColumn get title => text()();
TextColumn get description => text().nullable()();
TextColumn get goal => text().nullable()();
TextColumn get status => text()();
TextColumn get color => text().nullable()();
TextColumn get autoSummary => text().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get cachedAt =>
dateTime().clientDefault(() => DateTime.now())();
@override
Set<Column> get primaryKey => {id};
}
class CachedMilestones extends Table {
IntColumn get id => integer()();
IntColumn get projectId => integer()();
TextColumn get title => text()();
TextColumn get description => text().nullable()();
TextColumn get status => text()();
IntColumn get orderIndex => integer().withDefault(const Constant(0))();
IntColumn get total => integer().withDefault(const Constant(0))();
IntColumn get completed => integer().withDefault(const Constant(0))();
RealColumn get pct => real().withDefault(const Constant(0.0))();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get cachedAt =>
dateTime().clientDefault(() => DateTime.now())();
@override
Set<Column> get primaryKey => {id};
}
class CachedCalendarEvents extends Table {
IntColumn get id => integer()();
TextColumn get title => text()();
DateTimeColumn get startDt => dateTime()();
DateTimeColumn get endDt => dateTime().nullable()();
BoolColumn get allDay => boolean().withDefault(const Constant(false))();
TextColumn get description => text().withDefault(const Constant(''))();
TextColumn get location => text().withDefault(const Constant(''))();
TextColumn get color => text().withDefault(const Constant(''))();
TextColumn get recurrence => text().nullable()();
IntColumn get projectId => integer().nullable()();
IntColumn get reminderMinutes => integer().nullable()();
DateTimeColumn get cachedAt =>
dateTime().clientDefault(() => DateTime.now())();
@override
Set<Column> get primaryKey => {id};
}
class CachedConversations extends Table {
IntColumn get id => integer()();
TextColumn get title => text()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get cachedAt =>
dateTime().clientDefault(() => DateTime.now())();
@override
Set<Column> get primaryKey => {id};
}
class SyncMetadata extends Table {
TextColumn get domain => text()();
DateTimeColumn get lastSyncedAt => dateTime()();
@override
Set<Column> get primaryKey => {domain};
}
/// Phase 3 — generic offline-write queue. One row per pending API call;
/// drained in oldest-first order when the device comes back online.
///
/// `targetId` is the server id for updates/deletes; `tempId` is the
/// client-allocated negative placeholder for offline creates. `payloadJson`
/// is the typed args the API method needs (verb-specific shape).
/// `baselineUpdatedAt` snapshots the cached row's `updated_at` at edit
/// time so the replayer can drop the op if the server has moved on
/// (server-wins conflict resolution).
class PendingWrites extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get domain => text()();
TextColumn get verb => text()();
IntColumn get targetId => integer().nullable()();
IntColumn get tempId => integer().nullable()();
TextColumn get payloadJson => text().withDefault(const Constant('{}'))();
DateTimeColumn get baselineUpdatedAt => dateTime().nullable()();
DateTimeColumn get createdAt =>
dateTime().clientDefault(() => DateTime.now())();
IntColumn get tries => integer().withDefault(const Constant(0))();
TextColumn get lastError => text().nullable()();
}
const String kSyncDomainNotes = 'notes';
const String kSyncDomainTasks = 'tasks';
const String kSyncDomainProjects = 'projects';
const String kSyncDomainMilestones = 'milestones';
const String kSyncDomainEvents = 'events';
const String kSyncDomainConversations = 'conversations';
const String kWriteVerbCreate = 'create';
const String kWriteVerbUpdate = 'update';
const String kWriteVerbDelete = 'delete';
@DriftDatabase(tables: [
CachedNotes,
CachedTasks,
CachedProjects,
CachedMilestones,
CachedCalendarEvents,
CachedConversations,
SyncMetadata,
PendingWrites,
])
class FabledDatabase extends _$FabledDatabase {
FabledDatabase() : super(_openConnection());
FabledDatabase.forTesting(super.executor);
@override
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
// v1 → v2: added the per-domain caches beyond notes.
if (from < 2) {
await m.createTable(cachedTasks);
await m.createTable(cachedProjects);
await m.createTable(cachedMilestones);
await m.createTable(cachedCalendarEvents);
await m.createTable(cachedConversations);
}
// v2 → v3: added the offline write queue.
if (from < 3) {
await m.createTable(pendingWrites);
}
},
);
// ── Notes ────────────────────────────────────────────────────────────────
Future<List<Note>> getAllNotes() async {
final rows = await (select(cachedNotes)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
return rows.map(_noteFromRow).toList();
}
Future<Note?> getNote(int id) async {
final row = await (select(cachedNotes)..where((t) => t.id.equals(id)))
.getSingleOrNull();
return row == null ? null : _noteFromRow(row);
}
Future<void> replaceAllNotes(List<Note> notes) async {
await transaction(() async {
await delete(cachedNotes).go();
if (notes.isNotEmpty) {
await batch((b) {
b.insertAll(cachedNotes, notes.map(_noteToCompanion).toList());
});
}
await _setLastSyncIn(kSyncDomainNotes, DateTime.now());
});
}
Future<void> upsertNote(Note note) async {
await into(cachedNotes).insertOnConflictUpdate(_noteToCompanion(note));
}
Future<void> deleteNote(int id) async {
await (delete(cachedNotes)..where((t) => t.id.equals(id))).go();
}
// ── Tasks ────────────────────────────────────────────────────────────────
Future<List<Task>> getAllTasks() async {
final rows = await (select(cachedTasks)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
return rows.map(_taskFromRow).toList();
}
Future<Task?> getTask(int id) async {
final row = await (select(cachedTasks)..where((t) => t.id.equals(id)))
.getSingleOrNull();
return row == null ? null : _taskFromRow(row);
}
Future<List<Task>> getTasksByProject(int projectId) async {
final rows = await (select(cachedTasks)
..where((t) => t.projectId.equals(projectId))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
return rows.map(_taskFromRow).toList();
}
Future<List<Task>> getSubTasks(int parentId) async {
final rows = await (select(cachedTasks)
..where((t) => t.parentId.equals(parentId))
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
return rows.map(_taskFromRow).toList();
}
Future<void> replaceAllTasks(List<Task> tasks) async {
await transaction(() async {
await delete(cachedTasks).go();
if (tasks.isNotEmpty) {
await batch((b) {
b.insertAll(cachedTasks, tasks.map(_taskToCompanion).toList());
});
}
await _setLastSyncIn(kSyncDomainTasks, DateTime.now());
});
}
Future<void> upsertTask(Task task) async {
await into(cachedTasks).insertOnConflictUpdate(_taskToCompanion(task));
}
Future<void> deleteTask(int id) async {
await (delete(cachedTasks)..where((t) => t.id.equals(id))).go();
}
// ── Projects ─────────────────────────────────────────────────────────────
Future<List<Project>> getAllProjects() async {
final rows = await (select(cachedProjects)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
return rows.map(_projectFromRow).toList();
}
Future<Project?> getProject(int id) async {
final row = await (select(cachedProjects)..where((t) => t.id.equals(id)))
.getSingleOrNull();
return row == null ? null : _projectFromRow(row);
}
Future<void> replaceAllProjects(List<Project> projects) async {
await transaction(() async {
await delete(cachedProjects).go();
if (projects.isNotEmpty) {
await batch((b) {
b.insertAll(
cachedProjects,
projects.map(_projectToCompanion).toList(),
);
});
}
await _setLastSyncIn(kSyncDomainProjects, DateTime.now());
});
}
Future<void> upsertProject(Project project) async {
await into(cachedProjects)
.insertOnConflictUpdate(_projectToCompanion(project));
}
Future<void> deleteProject(int id) async {
await (delete(cachedProjects)..where((t) => t.id.equals(id))).go();
}
// ── Milestones ───────────────────────────────────────────────────────────
Future<List<Milestone>> getMilestonesForProject(int projectId) async {
final rows = await (select(cachedMilestones)
..where((t) => t.projectId.equals(projectId))
..orderBy([(t) => OrderingTerm.asc(t.orderIndex)]))
.get();
return rows.map(_milestoneFromRow).toList();
}
Future<void> replaceMilestonesForProject(
int projectId,
List<Milestone> milestones,
) async {
await transaction(() async {
await (delete(cachedMilestones)
..where((t) => t.projectId.equals(projectId)))
.go();
if (milestones.isNotEmpty) {
await batch((b) {
b.insertAll(
cachedMilestones,
milestones.map(_milestoneToCompanion).toList(),
);
});
}
await _setLastSyncIn(kSyncDomainMilestones, DateTime.now());
});
}
Future<void> upsertMilestone(Milestone milestone) async {
await into(cachedMilestones)
.insertOnConflictUpdate(_milestoneToCompanion(milestone));
}
Future<void> deleteMilestone(int id) async {
await (delete(cachedMilestones)..where((t) => t.id.equals(id))).go();
}
// ── Calendar events ──────────────────────────────────────────────────────
/// Returns events whose start_dt falls within [from, to). Recurrence
/// expansion still happens server-side; a stored event that has a
/// non-null recurrence rule will only have one row in cache (its
/// canonical instance), so cached date-range queries on recurring
/// events are best-effort and may miss future occurrences.
Future<List<CalendarEvent>> getEventsInRange(
DateTime from, DateTime to) async {
final rows = await (select(cachedCalendarEvents)
..where((t) =>
t.startDt.isBiggerOrEqualValue(from) &
t.startDt.isSmallerThanValue(to))
..orderBy([(t) => OrderingTerm.asc(t.startDt)]))
.get();
return rows.map(_eventFromRow).toList();
}
/// Replace the cache for the given range. Events outside [from, to)
/// are left alone — this matches the API's range-scoped semantics so
/// repeated fetches over disjoint ranges don't clobber each other.
Future<void> replaceEventsInRange(
DateTime from,
DateTime to,
List<CalendarEvent> events,
) async {
await transaction(() async {
await (delete(cachedCalendarEvents)
..where((t) =>
t.startDt.isBiggerOrEqualValue(from) &
t.startDt.isSmallerThanValue(to)))
.go();
if (events.isNotEmpty) {
await batch((b) {
b.insertAll(
cachedCalendarEvents,
events.map(_eventToCompanion).toList(),
);
});
}
await _setLastSyncIn(kSyncDomainEvents, DateTime.now());
});
}
Future<void> upsertEvent(CalendarEvent event) async {
await into(cachedCalendarEvents)
.insertOnConflictUpdate(_eventToCompanion(event));
}
Future<void> deleteEvent(int id) async {
await (delete(cachedCalendarEvents)..where((t) => t.id.equals(id))).go();
}
// ── Conversations (chat list — not messages) ─────────────────────────────
Future<List<Conversation>> getAllConversations() async {
final rows = await (select(cachedConversations)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
return rows.map(_conversationFromRow).toList();
}
Future<void> replaceAllConversations(
List<Conversation> conversations) async {
await transaction(() async {
await delete(cachedConversations).go();
if (conversations.isNotEmpty) {
await batch((b) {
b.insertAll(
cachedConversations,
conversations.map(_conversationToCompanion).toList(),
);
});
}
await _setLastSyncIn(kSyncDomainConversations, DateTime.now());
});
}
Future<void> deleteConversation(int id) async {
await (delete(cachedConversations)..where((t) => t.id.equals(id))).go();
}
// ── Sync metadata ────────────────────────────────────────────────────────
Future<DateTime?> getLastSync(String domain) async {
final row = await (select(syncMetadata)
..where((t) => t.domain.equals(domain)))
.getSingleOrNull();
return row?.lastSyncedAt;
}
/// Most recent sync across all domains. Used by the OfflineBanner so the
/// hint reads as "your data was current as of X" regardless of which
/// screen the user happens to be on.
Future<DateTime?> getLatestSync() async {
final row = await (select(syncMetadata)
..orderBy([(t) => OrderingTerm.desc(t.lastSyncedAt)])
..limit(1))
.getSingleOrNull();
return row?.lastSyncedAt;
}
Future<void> _setLastSyncIn(String domain, DateTime timestamp) {
return into(syncMetadata).insertOnConflictUpdate(
SyncMetadataCompanion(
domain: Value(domain),
lastSyncedAt: Value(timestamp),
),
);
}
// ── Pending writes (Phase 3 offline queue) ───────────────────────────────
/// Allocate a fresh negative id used as a placeholder for an
/// offline-created row until its server id arrives via replay.
Future<int> nextTempId() async {
final query = customSelect(
'SELECT MIN(temp_id) AS m FROM pending_writes WHERE temp_id IS NOT NULL',
);
final row = await query.getSingleOrNull();
final current = row?.read<int?>('m');
return (current ?? 0) - 1;
}
Future<int> enqueuePending({
required String domain,
required String verb,
int? targetId,
int? tempId,
Map<String, dynamic> payload = const {},
DateTime? baselineUpdatedAt,
}) {
return into(pendingWrites).insert(
PendingWritesCompanion.insert(
domain: domain,
verb: verb,
targetId: Value(targetId),
tempId: Value(tempId),
payloadJson: Value(jsonEncode(payload)),
baselineUpdatedAt: Value(baselineUpdatedAt),
),
);
}
/// Find the queued create-op for an offline-created row so a follow-up
/// edit can be coalesced into the original payload (no separate update
/// op gets queued). Returns null if no matching create is queued.
Future<PendingWrite?> findQueuedCreate(String domain, int tempId) {
return (select(pendingWrites)
..where((t) =>
t.domain.equals(domain) &
t.verb.equals(kWriteVerbCreate) &
t.tempId.equals(tempId)))
.getSingleOrNull();
}
Future<void> updatePendingPayload(int opId, Map<String, dynamic> payload) {
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))),
);
}
Future<void> deletePending(int opId) {
return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go();
}
/// Pending ops oldest first — replay order.
Future<List<PendingWrite>> listPending() {
return (select(pendingWrites)
..orderBy([(t) => OrderingTerm.asc(t.id)]))
.get();
}
Future<int> queueDepth() async {
final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes')
.getSingle();
return row.read<int>('c');
}
Future<void> markPendingFailed(int opId, String error) {
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
PendingWritesCompanion(
tries: const Value.absent(),
lastError: Value(error),
),
);
}
Future<void> incrementPendingTries(int opId) {
return customStatement(
'UPDATE pending_writes SET tries = tries + 1 WHERE id = ?',
[opId],
);
}
/// Watch queue depth — used by OfflineBanner for the "Retry (N)" affordance.
Stream<int> watchQueueDepth() {
final query = customSelect(
'SELECT COUNT(*) AS c FROM pending_writes',
readsFrom: {pendingWrites},
);
return query.watchSingle().map((row) => row.read<int>('c'));
}
/// Watch the set of ids in [domain] that have a queued write (either a
/// `target_id` for an update/delete or a `temp_id` for an offline create).
/// Phase 4 — used by per-row pending-sync indicators in the list views.
Stream<Set<int>> watchPendingIds(String domain) {
final query = customSelect(
'SELECT target_id, temp_id FROM pending_writes WHERE domain = ?',
variables: [Variable.withString(domain)],
readsFrom: {pendingWrites},
);
return query.watch().map((rows) {
final ids = <int>{};
for (final r in rows) {
final t = r.read<int?>('target_id');
final tmp = r.read<int?>('temp_id');
if (t != null) ids.add(t);
if (tmp != null) ids.add(tmp);
}
return ids;
});
}
}
// ── Row ↔ model converters ──────────────────────────────────────────────────
CachedNotesCompanion _noteToCompanion(Note n) => CachedNotesCompanion(
id: Value(n.id),
title: Value(n.title),
body: Value(n.body),
tagsJson: Value(jsonEncode(n.tags)),
noteType: Value(n.noteType),
projectId: Value(n.projectId),
milestoneId: Value(n.milestoneId),
createdAt: Value(n.createdAt),
updatedAt: Value(n.updatedAt),
);
Note _noteFromRow(CachedNote r) => Note(
id: r.id,
title: r.title,
body: r.body,
tags: (jsonDecode(r.tagsJson) as List<dynamic>).cast<String>(),
noteType: r.noteType,
projectId: r.projectId,
milestoneId: r.milestoneId,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
);
CachedTasksCompanion _taskToCompanion(Task t) => CachedTasksCompanion(
id: Value(t.id),
title: Value(t.title),
description: Value(t.description),
status: Value(t.status.value),
priority: Value(t.priority.value),
dueDate: Value(t.dueDate),
projectId: Value(t.projectId),
milestoneId: Value(t.milestoneId),
parentId: Value(t.parentId),
createdAt: Value(t.createdAt),
updatedAt: Value(t.updatedAt),
);
Task _taskFromRow(CachedTask r) => Task(
id: r.id,
title: r.title,
description: r.description,
status: TaskStatusExtension.fromString(r.status),
priority: TaskPriorityExtension.fromString(r.priority),
dueDate: r.dueDate,
projectId: r.projectId,
milestoneId: r.milestoneId,
parentId: r.parentId,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
);
CachedProjectsCompanion _projectToCompanion(Project p) =>
CachedProjectsCompanion(
id: Value(p.id),
title: Value(p.title),
description: Value(p.description),
goal: Value(p.goal),
status: Value(p.status),
color: Value(p.color),
autoSummary: Value(p.autoSummary),
createdAt: Value(p.createdAt),
updatedAt: Value(p.updatedAt),
);
Project _projectFromRow(CachedProject r) => Project(
id: r.id,
title: r.title,
description: r.description,
goal: r.goal,
status: r.status,
color: r.color,
autoSummary: r.autoSummary,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
);
CachedMilestonesCompanion _milestoneToCompanion(Milestone m) =>
CachedMilestonesCompanion(
id: Value(m.id),
projectId: Value(m.projectId),
title: Value(m.title),
description: Value(m.description),
status: Value(m.status),
orderIndex: Value(m.orderIndex),
total: Value(m.total),
completed: Value(m.completed),
pct: Value(m.pct),
createdAt: Value(m.createdAt),
updatedAt: Value(m.updatedAt),
);
Milestone _milestoneFromRow(CachedMilestone r) => Milestone(
id: r.id,
projectId: r.projectId,
title: r.title,
description: r.description,
status: r.status,
orderIndex: r.orderIndex,
total: r.total,
completed: r.completed,
pct: r.pct,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
);
CachedCalendarEventsCompanion _eventToCompanion(CalendarEvent e) =>
CachedCalendarEventsCompanion(
id: Value(e.id),
title: Value(e.title),
startDt: Value(e.startDt),
endDt: Value(e.endDt),
allDay: Value(e.allDay),
description: Value(e.description),
location: Value(e.location),
color: Value(e.color),
recurrence: Value(e.recurrence),
projectId: Value(e.projectId),
reminderMinutes: Value(e.reminderMinutes),
);
CalendarEvent _eventFromRow(CachedCalendarEvent r) => CalendarEvent(
id: r.id,
title: r.title,
startDt: r.startDt,
endDt: r.endDt,
allDay: r.allDay,
description: r.description,
location: r.location,
color: r.color,
recurrence: r.recurrence,
projectId: r.projectId,
reminderMinutes: r.reminderMinutes,
);
CachedConversationsCompanion _conversationToCompanion(Conversation c) =>
CachedConversationsCompanion(
id: Value(c.id),
title: Value(c.title),
createdAt: Value(c.createdAt),
updatedAt: Value(c.updatedAt),
);
Conversation _conversationFromRow(CachedConversation r) => Conversation(
id: r.id,
title: r.title,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
);
LazyDatabase _openConnection() {
return LazyDatabase(() async {
if (Platform.isAndroid) {
await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
}
final dir = await getApplicationDocumentsDirectory();
final file = File(p.join(dir.path, 'fabled_cache.sqlite'));
return NativeDatabase.createInBackground(file);
});
}
File diff suppressed because it is too large Load Diff
@@ -1,35 +0,0 @@
import 'message.dart';
class BriefingConversation {
final int id;
final String title;
final String? briefingDate; // YYYY-MM-DD or null
final List<Message> messages;
const BriefingConversation({
required this.id,
required this.title,
this.briefingDate,
required this.messages,
});
factory BriefingConversation.fromJson(Map<String, dynamic> json) {
final rawMessages = json['messages'] as List<dynamic>? ?? [];
return BriefingConversation(
id: json['id'] as int,
title: json['title'] as String? ?? '',
briefingDate: json['briefing_date'] as String?,
messages: rawMessages
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
BriefingConversation copyWith({List<Message>? messages}) =>
BriefingConversation(
id: id,
title: title,
briefingDate: briefingDate,
messages: messages ?? this.messages,
);
}
+47
View File
@@ -0,0 +1,47 @@
class CalendarEvent {
final int id;
final String title;
final DateTime startDt;
final DateTime? endDt;
final bool allDay;
final String description;
final String location;
final String color;
final String? recurrence;
final int? projectId;
final int? reminderMinutes;
const CalendarEvent({
required this.id,
required this.title,
required this.startDt,
this.endDt,
required this.allDay,
required this.description,
required this.location,
required this.color,
this.recurrence,
this.projectId,
this.reminderMinutes,
});
factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent(
id: json['id'] as int,
title: json['title'] as String? ?? '',
startDt: DateTime.parse(json['start_dt'] as String).toLocal(),
endDt: json['end_dt'] != null
? DateTime.parse(json['end_dt'] as String).toLocal()
: null,
allDay: json['all_day'] as bool? ?? false,
description: json['description'] as String? ?? '',
location: json['location'] as String? ?? '',
color: json['color'] as String? ?? '',
recurrence: json['recurrence'] as String?,
projectId: json['project_id'] as int?,
reminderMinutes: json['reminder_minutes'] as int?,
);
}
/// Strips time from a DateTime, returning midnight local.
/// Used as map keys in CalendarState.eventsByDay.
DateTime dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
+60
View File
@@ -0,0 +1,60 @@
import 'message.dart';
/// Lightweight conversation header for a journal day — just enough to drive
/// navigation and labels. The full message list comes alongside in [JournalDay].
class JournalConversation {
final int id;
final String title;
final String conversationType;
final String? dayDate; // YYYY-MM-DD or null
const JournalConversation({
required this.id,
required this.title,
required this.conversationType,
this.dayDate,
});
factory JournalConversation.fromJson(Map<String, dynamic> json) =>
JournalConversation(
id: json['id'] as int,
title: json['title'] as String? ?? '',
conversationType:
json['conversation_type'] as String? ?? 'journal',
dayDate: json['day_date'] as String?,
);
}
/// Payload returned by GET /api/journal/today and /api/journal/day/&lt;iso&gt;.
/// `conversation` is null on a day with no journal content yet (rare —
/// the today endpoint creates it on demand).
class JournalDay {
final String dayDate;
final JournalConversation? conversation;
final List<Message> messages;
const JournalDay({
required this.dayDate,
required this.conversation,
required this.messages,
});
factory JournalDay.fromJson(Map<String, dynamic> json) {
final convRaw = json['conversation'] as Map<String, dynamic>?;
final rawMessages = json['messages'] as List<dynamic>? ?? [];
return JournalDay(
dayDate: json['day_date'] as String,
conversation:
convRaw == null ? null : JournalConversation.fromJson(convRaw),
messages: rawMessages
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
JournalDay copyWith({List<Message>? messages}) => JournalDay(
dayDate: dayDate,
conversation: conversation,
messages: messages ?? this.messages,
);
}
+69
View File
@@ -0,0 +1,69 @@
import 'task.dart';
class KnowledgeItem {
final int id;
final String noteType; // 'note' | 'person' | 'place' | 'list' | 'task'
final String title;
final String body;
final List<String> tags;
final int? projectId;
final int? milestoneId;
final int? parentId;
// Task-only fields (null for non-tasks)
final String? status; // 'todo' | 'in_progress' | 'done' | 'cancelled'
final String? priority; // 'low' | 'normal' | 'high'
final String? dueDate;
final DateTime createdAt;
final DateTime updatedAt;
const KnowledgeItem({
required this.id,
required this.noteType,
required this.title,
required this.body,
required this.tags,
this.projectId,
this.milestoneId,
this.parentId,
this.status,
this.priority,
this.dueDate,
required this.createdAt,
required this.updatedAt,
});
factory KnowledgeItem.fromTask(Task task) => KnowledgeItem(
id: task.id,
noteType: 'task',
title: task.title,
body: task.description ?? '',
tags: const [],
projectId: task.projectId,
milestoneId: task.milestoneId,
parentId: task.parentId,
status: task.status.value,
priority: task.priority.value,
dueDate: task.dueDate?.toIso8601String(),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
);
factory KnowledgeItem.fromJson(Map<String, dynamic> json) => KnowledgeItem(
id: json['id'] as int,
noteType: json['note_type'] as String? ?? 'note',
title: json['title'] as String? ?? '',
body: (json['snippet'] ?? json['body']) as String? ?? '',
tags: (json['tags'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
projectId: json['project_id'] as int?,
milestoneId: json['milestone_id'] as int?,
parentId: json['parent_id'] as int?,
status: json['status'] as String?,
priority: json['priority'] as String?,
dueDate: json['due_date'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
+36 -12
View File
@@ -8,6 +8,11 @@ class Message {
final String status; // "complete" | "generating"
final DateTime? createdAt;
final Map<String, dynamic>? metadata;
// Tool invocations attached to this message. Each entry matches the shape
// persisted by the backend (`function`, `arguments`, `result`, `status`) so
// the UI can render the same chips whether they arrive live over SSE or
// from a reload.
final List<Map<String, dynamic>>? toolCalls;
const Message({
this.id,
@@ -17,21 +22,39 @@ class Message {
this.status = 'complete',
this.createdAt,
this.metadata,
this.toolCalls,
});
factory Message.fromJson(Map<String, dynamic> json) => Message(
id: json['id'] as int?,
conversationId: json['conversation_id'] as int,
role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant,
content: json['content'] as String,
status: json['status'] as String? ?? 'complete',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
metadata: json['metadata'] as Map<String, dynamic>?,
);
factory Message.fromJson(Map<String, dynamic> json) {
final rawCalls = json['tool_calls'];
List<Map<String, dynamic>>? parsedCalls;
if (rawCalls is List) {
parsedCalls = [
for (final tc in rawCalls)
if (tc is Map<String, dynamic>) tc,
];
if (parsedCalls.isEmpty) parsedCalls = null;
}
return Message(
id: json['id'] as int?,
conversationId: json['conversation_id'] as int,
role: json['role'] == 'user' ? MessageRole.user : MessageRole.assistant,
content: json['content'] as String,
status: json['status'] as String? ?? 'complete',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
metadata: json['metadata'] as Map<String, dynamic>?,
toolCalls: parsedCalls,
);
}
Message copyWith({String? content, String? status}) => Message(
Message copyWith({
String? content,
String? status,
List<Map<String, dynamic>>? toolCalls,
}) =>
Message(
id: id,
conversationId: conversationId,
role: role,
@@ -39,5 +62,6 @@ class Message {
status: status ?? this.status,
createdAt: createdAt,
metadata: metadata,
toolCalls: toolCalls ?? this.toolCalls,
);
}
+6
View File
@@ -3,6 +3,7 @@ class Note {
final String title;
final String body;
final List<String> tags;
final String noteType;
final int? projectId;
final int? milestoneId;
final DateTime createdAt;
@@ -13,6 +14,7 @@ class Note {
required this.title,
required this.body,
required this.tags,
this.noteType = 'note',
this.projectId,
this.milestoneId,
required this.createdAt,
@@ -27,6 +29,7 @@ class Note {
?.map((e) => e as String)
.toList() ??
[],
noteType: json['note_type'] as String? ?? 'note',
projectId: json['project_id'] as int?,
milestoneId: json['milestone_id'] as int?,
createdAt: DateTime.parse(json['created_at'] as String),
@@ -37,6 +40,7 @@ class Note {
'title': title,
'body': body,
'tags': tags,
'note_type': noteType,
'project_id': projectId,
'milestone_id': milestoneId,
};
@@ -45,6 +49,7 @@ class Note {
String? title,
String? body,
List<String>? tags,
String? noteType,
Object? projectId = _undefined,
Object? milestoneId = _undefined,
}) =>
@@ -53,6 +58,7 @@ class Note {
title: title ?? this.title,
body: body ?? this.body,
tags: tags ?? this.tags,
noteType: noteType ?? this.noteType,
projectId: identical(projectId, _undefined)
? this.projectId
: projectId as int?,
+4
View File
@@ -5,6 +5,7 @@ class Project {
final String? goal;
final String status; // active | completed | archived
final String? color;
final String? autoSummary;
final DateTime createdAt;
final DateTime updatedAt;
@@ -15,6 +16,7 @@ class Project {
this.goal,
required this.status,
this.color,
this.autoSummary,
required this.createdAt,
required this.updatedAt,
});
@@ -26,6 +28,7 @@ class Project {
goal: json['goal'] as String?,
status: json['status'] as String? ?? 'active',
color: json['color'] as String?,
autoSummary: json['auto_summary'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
@@ -36,5 +39,6 @@ class Project {
'goal': goal,
'status': status,
'color': color,
'auto_summary': autoSummary,
};
}
+31 -4
View File
@@ -1,19 +1,46 @@
import '../../core/exceptions.dart';
import '../api/chat_api.dart';
export '../api/chat_api.dart'
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
import '../local/database.dart';
import '../models/conversation.dart';
import '../models/message.dart';
/// Chat repository with read-through caching for the conversation list only.
/// Messages and the live SSE stream are intentionally not cached — they are
/// per-conversation, large, and require a live network anyway.
class ChatRepository {
final ChatApi _api;
const ChatRepository(this._api);
final FabledDatabase _db;
const ChatRepository(this._api, this._db);
Future<List<Conversation>> getConversations() async {
try {
final conversations = await _api.getConversations();
await _db.replaceAllConversations(conversations);
return conversations;
} on NetworkException {
final cached = await _db.getAllConversations();
if (cached.isEmpty) rethrow;
return cached;
}
}
Future<List<Conversation>> getConversations() => _api.getConversations();
Future<Conversation> createConversation(String title) =>
_api.createConversation(title);
Future<void> deleteConversation(int id) => _api.deleteConversation(id);
Future<void> deleteConversation(int id) async {
await _api.deleteConversation(id);
await _db.deleteConversation(id);
}
Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
_api.getMessages(conversationId);
Future<void> sendMessage(int conversationId, String content) =>
_api.sendMessage(conversationId, content);
Stream<String> streamGeneration(int conversationId) =>
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
_api.streamGeneration(conversationId);
}
@@ -0,0 +1,144 @@
import '../../core/exceptions.dart';
import '../api/events_api.dart';
import '../local/database.dart';
import '../models/calendar_event.dart';
/// Calendar events repository with read-through caching (Phase 1+2) and
/// offline-write queueing (Phase 3). Range-scoped reads: `getEvents(from, to)`
/// mirrors that window into the cache (events outside the window are left
/// alone) so successive disjoint range fetches don't clobber each other.
///
/// CalendarEvent has no `updatedAt` field, so write replay uses last-
/// writer-wins on update/delete (no server-side baseline check).
class EventsRepository {
final EventsApi _api;
final FabledDatabase _db;
const EventsRepository(this._api, this._db);
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
try {
final events = await _api.getEvents(from, to);
await _db.replaceEventsInRange(from, to, events);
return events;
} on NetworkException {
final cached = await _db.getEventsInRange(from, to);
if (cached.isEmpty) rethrow;
return cached;
}
}
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
try {
final event = await _api.createEvent(payload);
await _db.upsertEvent(event);
return event;
} on NetworkException {
final tempId = await _db.nextTempId();
final optimistic = _eventFromPayload(tempId, payload);
await _db.upsertEvent(optimistic);
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbCreate,
tempId: tempId,
payload: payload,
);
return optimistic;
}
}
Future<CalendarEvent> updateEvent(
int id, Map<String, dynamic> fields) async {
try {
final updated = await _api.updateEvent(id, fields);
await _db.upsertEvent(updated);
return updated;
} on NetworkException {
final cached = (await _db.getEventsInRange(
DateTime.fromMicrosecondsSinceEpoch(0),
DateTime.now().add(const Duration(days: 365 * 100)),
))
.where((e) => e.id == id)
.firstOrNull;
if (cached == null) rethrow;
final optimistic = _applyEventFields(cached, fields);
await _db.upsertEvent(optimistic);
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, fields);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbUpdate,
targetId: id,
payload: fields,
);
return optimistic;
}
}
Future<void> deleteEvent(int id) async {
try {
await _api.deleteEvent(id);
await _db.deleteEvent(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteEvent(id);
return;
}
await _db.deleteEvent(id);
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbDelete,
targetId: id,
);
}
}
}
CalendarEvent _eventFromPayload(int id, Map<String, dynamic> p) {
return CalendarEvent(
id: id,
title: p['title'] as String? ?? '',
startDt: _parseIso(p['start_dt'])!,
endDt: _parseIso(p['end_dt']),
allDay: p['all_day'] as bool? ?? false,
description: p['description'] as String? ?? '',
location: p['location'] as String? ?? '',
color: p['color'] as String? ?? '',
recurrence: p['recurrence'] as String?,
projectId: p['project_id'] as int?,
reminderMinutes: p['reminder_minutes'] as int?,
);
}
CalendarEvent _applyEventFields(CalendarEvent e, Map<String, dynamic> f) {
return CalendarEvent(
id: e.id,
title: f['title'] as String? ?? e.title,
startDt: _parseIso(f['start_dt']) ?? e.startDt,
endDt: f.containsKey('end_dt') ? _parseIso(f['end_dt']) : e.endDt,
allDay: f['all_day'] as bool? ?? e.allDay,
description: f['description'] as String? ?? e.description,
location: f['location'] as String? ?? e.location,
color: f['color'] as String? ?? e.color,
recurrence:
f.containsKey('recurrence') ? f['recurrence'] as String? : e.recurrence,
projectId: f.containsKey('project_id')
? f['project_id'] as int?
: e.projectId,
reminderMinutes: f.containsKey('reminder_minutes')
? f['reminder_minutes'] as int?
: e.reminderMinutes,
);
}
DateTime? _parseIso(dynamic raw) {
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw)?.toLocal();
return null;
}
@@ -0,0 +1,33 @@
import '../api/knowledge_api.dart';
import '../models/knowledge_item.dart';
class KnowledgeRepository {
final KnowledgeApi _api;
const KnowledgeRepository(this._api);
Future<(List<int>, int)> fetchIds({
String? noteType,
List<String> tags = const [],
String sort = 'modified',
String? q,
int limit = 50,
int offset = 0,
}) =>
_api.fetchIds(
noteType: noteType,
tags: tags,
sort: sort,
q: q,
limit: limit,
offset: offset,
);
Future<List<KnowledgeItem>> fetchBatch(List<int> ids) =>
_api.fetchBatch(ids);
Future<Map<String, int>> fetchCounts({List<String> tags = const []}) =>
_api.fetchCounts(tags: tags);
Future<List<String>> fetchTags({String? noteType}) =>
_api.fetchTags(noteType: noteType);
}
+148 -10
View File
@@ -1,26 +1,164 @@
import '../../core/exceptions.dart';
import '../api/milestones_api.dart';
import '../local/database.dart';
import '../models/milestone.dart';
/// Milestones repository with read-through caching (Phase 1+2) and
/// offline-write queueing (Phase 3). See `notes_repository.dart` for the
/// full pattern.
class MilestonesRepository {
final MilestonesApi _api;
const MilestonesRepository(this._api);
final FabledDatabase _db;
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
_api.getAll(projectId, status: status);
const MilestonesRepository(this._api, this._db);
Future<List<Milestone>> getAll(int projectId, {String? status}) async {
try {
final milestones = await _api.getAll(projectId, status: status);
// Only mirror the full per-project list to cache on the unfiltered
// fetch; a status filter would otherwise drop entries from cache.
if (status == null) {
await _db.replaceMilestonesForProject(projectId, milestones);
} else {
for (final m in milestones) {
await _db.upsertMilestone(m);
}
}
return milestones;
} on NetworkException {
final cached = await _db.getMilestonesForProject(projectId);
if (cached.isEmpty) rethrow;
if (status != null) {
return cached.where((m) => m.status == status).toList();
}
return cached;
}
}
Future<Milestone> create(
int projectId, {
required String title,
String? description,
int orderIndex = 0,
}) =>
_api.create(projectId,
title: title, description: description, orderIndex: orderIndex);
}) async {
try {
final milestone = await _api.create(
projectId,
title: title,
description: description,
orderIndex: orderIndex,
);
await _db.upsertMilestone(milestone);
return milestone;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Milestone(
id: tempId,
projectId: projectId,
title: title,
description: description,
status: 'active',
orderIndex: orderIndex,
total: 0,
completed: 0,
pct: 0.0,
createdAt: now,
updatedAt: now,
);
await _db.upsertMilestone(optimistic);
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'project_id': projectId,
'title': title,
'description': description,
'order_index': orderIndex,
},
);
return optimistic;
}
}
Future<Milestone> update(
int projectId, int milestoneId, Map<String, dynamic> fields) =>
_api.update(projectId, milestoneId, fields);
int projectId,
int milestoneId,
Map<String, dynamic> fields,
) async {
try {
final updated = await _api.update(projectId, milestoneId, fields);
await _db.upsertMilestone(updated);
return updated;
} on NetworkException {
final cached = (await _db.getMilestonesForProject(projectId))
.where((m) => m.id == milestoneId)
.firstOrNull;
if (cached == null) rethrow;
final optimistic = _applyMilestoneFields(cached, fields);
await _db.upsertMilestone(optimistic);
if (milestoneId < 0) {
final queued =
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
if (queued != null) {
// Carry project_id through coalesced payload — replay needs it.
final merged = <String, dynamic>{
'project_id': projectId,
...fields,
};
await _db.updatePendingPayload(queued.id, merged);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbUpdate,
targetId: milestoneId,
payload: <String, dynamic>{'project_id': projectId, ...fields},
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
}
Future<void> delete(int projectId, int milestoneId) =>
_api.delete(projectId, milestoneId);
Future<void> delete(int projectId, int milestoneId) async {
try {
await _api.delete(projectId, milestoneId);
await _db.deleteMilestone(milestoneId);
} on NetworkException {
if (milestoneId < 0) {
final queued =
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteMilestone(milestoneId);
return;
}
await _db.deleteMilestone(milestoneId);
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbDelete,
targetId: milestoneId,
payload: {'project_id': projectId},
);
}
}
}
Milestone _applyMilestoneFields(Milestone m, Map<String, dynamic> f) {
return Milestone(
id: m.id,
projectId: m.projectId,
title: f['title'] as String? ?? m.title,
description: f.containsKey('description')
? f['description'] as String?
: m.description,
status: f['status'] as String? ?? m.status,
orderIndex: f['order_index'] as int? ?? m.orderIndex,
total: m.total,
completed: m.completed,
pct: m.pct,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
);
}
+157 -9
View File
@@ -1,20 +1,100 @@
import '../../core/exceptions.dart';
import '../api/notes_api.dart';
import '../local/database.dart';
import '../models/note.dart';
/// Notes repository with read-through caching for Tier 2 offline support
/// (Phase 1+2) and offline-write queueing (Phase 3).
///
/// Reads attempt the network first; on success the response is written to
/// the local Drift cache. On `NetworkException` the read falls back to the
/// cache (rethrowing if the cache is empty so the UI can show its
/// fresh-install empty state).
///
/// Writes hit the server then sync the cache. On `NetworkException` the
/// write is queued in `pending_writes` and applied optimistically to the
/// cache (negative temp-id for creates, in-place update for edits, removal
/// for deletes). Subsequent edits to a temp-id row are coalesced into the
/// queued create — only one server call is made per offline-created row.
///
/// `AuthStatus.offline` is owned by `AuthNotifier.verify()` (a periodic
/// heartbeat). This repository deliberately does not poke that state.
class NotesRepository {
final NotesApi _api;
const NotesRepository(this._api);
final FabledDatabase _db;
Future<List<Note>> getAll() => _api.getAll();
Future<Note> getOne(int id) => _api.getOne(id);
const NotesRepository(this._api, this._db);
Future<List<Note>> getAll() async {
try {
final notes = await _api.getAll();
await _db.replaceAllNotes(notes);
return notes;
} on NetworkException {
final cached = await _db.getAllNotes();
if (cached.isEmpty) rethrow;
return cached;
}
}
Future<Note> getOne(int id) async {
try {
final note = await _api.getOne(id);
await _db.upsertNote(note);
return note;
} on NetworkException {
final cached = await _db.getNote(id);
if (cached == null) rethrow;
return cached;
}
}
Future<Note> create(
String title,
String body, {
List<String> tags = const [],
int? projectId,
}) =>
_api.create(title, body, tags: tags, projectId: projectId);
String noteType = 'note',
}) async {
try {
final note = await _api.create(
title,
body,
tags: tags,
projectId: projectId,
noteType: noteType,
);
await _db.upsertNote(note);
return note;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Note(
id: tempId,
title: title,
body: body,
tags: tags,
noteType: noteType,
projectId: projectId,
createdAt: now,
updatedAt: now,
);
await _db.upsertNote(optimistic);
await _db.enqueuePending(
domain: kSyncDomainNotes,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'title': title,
'body': body,
'tags': tags,
'project_id': projectId,
'note_type': noteType,
},
);
return optimistic;
}
}
Future<Note> update(
int id,
@@ -23,9 +103,77 @@ class NotesRepository {
List<String> tags = const [],
int? projectId,
bool clearProject = false,
}) =>
_api.update(id, title, body,
tags: tags, projectId: projectId, clearProject: clearProject);
String noteType = 'note',
}) async {
try {
final updated = await _api.update(
id,
title,
body,
tags: tags,
projectId: projectId,
clearProject: clearProject,
noteType: noteType,
);
await _db.upsertNote(updated);
return updated;
} on NetworkException {
final cached = await _db.getNote(id);
if (cached == null) rethrow;
final payload = <String, dynamic>{
'title': title,
'body': body,
'tags': tags,
'project_id': projectId,
'clear_project': clearProject,
'note_type': noteType,
};
final optimistic = cached.copyWith(
title: title,
body: body,
tags: tags,
noteType: noteType,
projectId: clearProject ? null : projectId,
);
await _db.upsertNote(optimistic);
// Edits to an offline-created row coalesce into the queued create.
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, payload);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainNotes,
verb: kWriteVerbUpdate,
targetId: id,
payload: payload,
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
}
Future<void> delete(int id) => _api.delete(id);
Future<void> delete(int id) async {
try {
await _api.delete(id);
await _db.deleteNote(id);
} on NetworkException {
// Deleting an offline-created row that never reached the server
// just drops the queued create — no server call needed.
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteNote(id);
return;
}
await _db.deleteNote(id);
await _db.enqueuePending(
domain: kSyncDomainNotes,
verb: kWriteVerbDelete,
targetId: id,
);
}
}
}
+156 -9
View File
@@ -1,21 +1,168 @@
import '../../core/exceptions.dart';
import '../api/projects_api.dart';
import '../local/database.dart';
import '../models/project.dart';
/// Projects repository with read-through caching (Phase 1+2) and offline-write
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
///
/// `getAll`'s sort/order/status query parameters are server-side filters; the
/// cache stores the unfiltered list as last seen. Offline fallback returns
/// the full cache regardless of the requested filters — close enough for a
/// "view what you had" experience while disconnected.
class ProjectsRepository {
final ProjectsApi _api;
const ProjectsRepository(this._api);
final FabledDatabase _db;
const ProjectsRepository(this._api, this._db);
Future<List<Project>> getAll({
String? status,
String sort = 'updated_at',
String order = 'desc',
}) async {
try {
final projects =
await _api.getAll(status: status, sort: sort, order: order);
// Only refresh the cache on the unfiltered default fetch — otherwise a
// status=archived call would clobber the active-projects cache.
if (status == null) {
await _db.replaceAllProjects(projects);
} else {
for (final p in projects) {
await _db.upsertProject(p);
}
}
return projects;
} on NetworkException {
final cached = await _db.getAllProjects();
if (cached.isEmpty) rethrow;
if (status != null) {
return cached.where((p) => p.status == status).toList();
}
return cached;
}
}
Future<Project> getOne(int id) async {
try {
final project = await _api.getOne(id);
await _db.upsertProject(project);
return project;
} on NetworkException {
final cached = await _db.getProject(id);
if (cached == null) rethrow;
return cached;
}
}
Future<List<Project>> getAll({String? status}) => _api.getAll(status: status);
Future<Project> getOne(int id) => _api.getOne(id);
Future<Project> create({
required String title,
String? description,
String? goal,
String? color,
}) =>
_api.create(
title: title, description: description, goal: goal, color: color);
Future<Project> update(int id, Map<String, dynamic> fields) =>
_api.update(id, fields);
Future<void> delete(int id) => _api.delete(id);
String status = 'active',
}) async {
try {
final project = await _api.create(
title: title,
description: description,
goal: goal,
color: color,
status: status,
);
await _db.upsertProject(project);
return project;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Project(
id: tempId,
title: title,
description: description,
goal: goal,
status: status,
color: color,
createdAt: now,
updatedAt: now,
);
await _db.upsertProject(optimistic);
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'title': title,
'description': description,
'goal': goal,
'color': color,
'status': status,
},
);
return optimistic;
}
}
Future<Project> update(int id, Map<String, dynamic> fields) async {
try {
final updated = await _api.update(id, fields);
await _db.upsertProject(updated);
return updated;
} on NetworkException {
final cached = await _db.getProject(id);
if (cached == null) rethrow;
final optimistic = _applyProjectFields(cached, fields);
await _db.upsertProject(optimistic);
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, fields);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbUpdate,
targetId: id,
payload: fields,
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
}
Future<void> delete(int id) async {
try {
await _api.delete(id);
await _db.deleteProject(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteProject(id);
return;
}
await _db.deleteProject(id);
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbDelete,
targetId: id,
);
}
}
}
Project _applyProjectFields(Project p, Map<String, dynamic> f) {
return Project(
id: p.id,
title: f['title'] as String? ?? p.title,
description:
f.containsKey('description') ? f['description'] as String? : p.description,
goal: f.containsKey('goal') ? f['goal'] as String? : p.goal,
status: f['status'] as String? ?? p.status,
color: f.containsKey('color') ? f['color'] as String? : p.color,
autoSummary: p.autoSummary,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
);
}
+170 -11
View File
@@ -1,12 +1,63 @@
import '../../core/exceptions.dart';
import '../api/tasks_api.dart';
import '../local/database.dart';
import '../models/task.dart';
/// Tasks repository with read-through caching (Phase 1+2) and offline-write
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
class TasksRepository {
final TasksApi _api;
const TasksRepository(this._api);
final FabledDatabase _db;
Future<List<Task>> getAll() => _api.getAll();
Future<Task> getOne(int id) => _api.getOne(id);
const TasksRepository(this._api, this._db);
Future<List<Task>> getAll() async {
try {
final tasks = await _api.getAll();
await _db.replaceAllTasks(tasks);
return tasks;
} on NetworkException {
final cached = await _db.getAllTasks();
if (cached.isEmpty) rethrow;
return cached;
}
}
Future<Task> getOne(int id) async {
try {
final task = await _api.getOne(id);
await _db.upsertTask(task);
return task;
} on NetworkException {
final cached = await _db.getTask(id);
if (cached == null) rethrow;
return cached;
}
}
Future<List<Task>> getByProject(int projectId) async {
try {
final tasks = await _api.getByProject(projectId);
for (final t in tasks) {
await _db.upsertTask(t);
}
return tasks;
} on NetworkException {
return _db.getTasksByProject(projectId);
}
}
Future<List<Task>> getSubTasks(int parentId) async {
try {
final tasks = await _api.getSubTasks(parentId);
for (final t in tasks) {
await _db.upsertTask(t);
}
return tasks;
} on NetworkException {
return _db.getSubTasks(parentId);
}
}
Future<Task> create({
required String title,
@@ -16,8 +67,9 @@ class TasksRepository {
DateTime? dueDate,
int? projectId,
int? parentId,
}) =>
_api.create(
}) async {
try {
final task = await _api.create(
title: title,
description: description,
status: status,
@@ -26,12 +78,119 @@ class TasksRepository {
projectId: projectId,
parentId: parentId,
);
await _db.upsertTask(task);
return task;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Task(
id: tempId,
title: title,
description: description,
status: status,
priority: priority,
dueDate: dueDate,
projectId: projectId,
parentId: parentId,
createdAt: now,
updatedAt: now,
);
await _db.upsertTask(optimistic);
await _db.enqueuePending(
domain: kSyncDomainTasks,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'title': title,
'description': description,
'status': status.value,
'priority': priority.value,
'due_date': dueDate?.toIso8601String(),
'project_id': projectId,
'parent_id': parentId,
},
);
return optimistic;
}
}
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
Future<Task> update(int id, Map<String, dynamic> fields) async {
try {
final updated = await _api.update(id, fields);
await _db.upsertTask(updated);
return updated;
} on NetworkException {
final cached = await _db.getTask(id);
if (cached == null) rethrow;
final optimistic = _applyTaskFields(cached, fields);
await _db.upsertTask(optimistic);
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, fields);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainTasks,
verb: kWriteVerbUpdate,
targetId: id,
payload: fields,
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
}
Future<Task> update(int id, Map<String, dynamic> fields) =>
_api.update(id, fields);
Future<void> delete(int id) => _api.delete(id);
Future<void> delete(int id) async {
try {
await _api.delete(id);
await _db.deleteTask(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteTask(id);
return;
}
await _db.deleteTask(id);
await _db.enqueuePending(
domain: kSyncDomainTasks,
verb: kWriteVerbDelete,
targetId: id,
);
}
}
}
/// Applies a partial-fields map (the same shape sent to the PUT endpoint)
/// to a cached Task so the UI can show the edit immediately.
Task _applyTaskFields(Task t, Map<String, dynamic> f) {
return Task(
id: t.id,
title: f['title'] as String? ?? t.title,
description:
f.containsKey('body') ? f['body'] as String? : t.description,
status: f.containsKey('status')
? TaskStatusExtension.fromString(f['status'] as String?)
: t.status,
priority: f.containsKey('priority')
? TaskPriorityExtension.fromString(f['priority'] as String?)
: t.priority,
dueDate: f.containsKey('due_date')
? (f['due_date'] is String
? DateTime.tryParse(f['due_date'] as String)
: null)
: t.dueDate,
projectId: f.containsKey('project_id')
? f['project_id'] as int?
: t.projectId,
milestoneId: f.containsKey('milestone_id')
? f['milestone_id'] as int?
: t.milestoneId,
parentId:
f.containsKey('parent_id') ? f['parent_id'] as int? : t.parentId,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
);
}
@@ -0,0 +1,13 @@
import 'dart:typed_data';
import '../api/voice_api.dart';
class VoiceRepository {
final VoiceApi _api;
const VoiceRepository(this._api);
Future<VoiceStatus> checkStatus() => _api.checkStatus();
Future<String> transcribe(Uint8List audioBytes, {String? context}) =>
_api.transcribe(audioBytes, context: context);
Future<Uint8List> synthesise(String text) => _api.synthesise(text);
}
+385
View File
@@ -0,0 +1,385 @@
import 'dart:async';
import 'dart:convert';
import '../../core/exceptions.dart';
import '../api/events_api.dart';
import '../api/milestones_api.dart';
import '../api/notes_api.dart';
import '../api/projects_api.dart';
import '../api/tasks_api.dart';
import '../local/database.dart';
import '../models/note.dart';
import '../models/project.dart';
import '../models/task.dart';
/// Reasons a queued write was dropped during replay. Surfaced to the UI as
/// a one-time SnackBar so the user knows their offline edit didn't land.
enum QueueFailureReason { overwritten, missing, rejected }
class QueueFailure {
final QueueFailureReason reason;
final String domain;
final String? title;
final String? detail;
const QueueFailure({
required this.reason,
required this.domain,
this.title,
this.detail,
});
String get message {
final label = title?.isNotEmpty == true ? '"$title"' : 'an offline edit';
return switch (reason) {
QueueFailureReason.overwritten =>
'$label was overwritten by a newer change on the server.',
QueueFailureReason.missing =>
'$label was deleted on the server before your offline edit could be saved.',
QueueFailureReason.rejected =>
'Failed to save $label: ${detail ?? 'rejected by server.'}',
};
}
}
/// Drains the offline write queue (Phase 3). Owns the network → server
/// dispatch logic for every queued op; repos write to `pending_writes`,
/// this service replays them.
///
/// Replay semantics:
/// - oldest-first, single in-flight `replay()` call
/// - on `NetworkException` (or 5xx): leave op in place, stop the loop
/// - on conflict (server `updated_at` newer than op baseline): drop +
/// surface as `overwritten`
/// - on 404: for delete → silent success; for update → drop + `missing`
/// - on other 4xx: drop + `rejected`
class WriteQueue {
final FabledDatabase _db;
final NotesApi _notesApi;
final TasksApi _tasksApi;
final ProjectsApi _projectsApi;
final MilestonesApi _milestonesApi;
final EventsApi _eventsApi;
final StreamController<QueueFailure> _failures =
StreamController<QueueFailure>.broadcast();
bool _replaying = false;
WriteQueue({
required FabledDatabase db,
required NotesApi notesApi,
required TasksApi tasksApi,
required ProjectsApi projectsApi,
required MilestonesApi milestonesApi,
required EventsApi eventsApi,
}) : _db = db,
_notesApi = notesApi,
_tasksApi = tasksApi,
_projectsApi = projectsApi,
_milestonesApi = milestonesApi,
_eventsApi = eventsApi;
Stream<QueueFailure> get failures => _failures.stream;
void dispose() => _failures.close();
/// Drain the queue. Reentrant calls are no-ops while a replay is in
/// flight — the in-flight one will see new entries on its next iteration.
Future<void> replay() async {
if (_replaying) return;
_replaying = true;
try {
while (true) {
final ops = await _db.listPending();
if (ops.isEmpty) break;
final op = ops.first;
final keepGoing = await _replayOne(op);
if (!keepGoing) break;
}
} finally {
_replaying = false;
}
}
Future<bool> _replayOne(PendingWrite op) async {
final payload = jsonDecode(op.payloadJson) as Map<String, dynamic>;
await _db.incrementPendingTries(op.id);
try {
switch (op.domain) {
case kSyncDomainNotes:
await _replayNote(op, payload);
case kSyncDomainTasks:
await _replayTask(op, payload);
case kSyncDomainProjects:
await _replayProject(op, payload);
case kSyncDomainMilestones:
await _replayMilestone(op, payload);
case kSyncDomainEvents:
await _replayEvent(op, payload);
default:
// Unknown domain — drop so we don't loop forever.
await _db.deletePending(op.id);
return true;
}
await _db.deletePending(op.id);
return true;
} on NetworkException catch (e) {
// Still offline — leave op for next replay trigger.
await _db.markPendingFailed(op.id, e.message);
return false;
} on ServerException catch (e) {
if (e.statusCode >= 500) {
// Likely transient — keep + stop the loop, retry next time.
await _db.markPendingFailed(op.id, e.message);
return false;
}
// 4xx — non-retryable. Drop + surface.
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.rejected,
domain: op.domain,
title: payload['title'] as String?,
detail: e.message,
));
return true;
} on NotFoundException {
// Target gone server-side. Update → tell user; delete → silent success.
await _db.deletePending(op.id);
if (op.verb != kWriteVerbDelete) {
_failures.add(QueueFailure(
reason: QueueFailureReason.missing,
domain: op.domain,
title: payload['title'] as String?,
));
}
// Clean the cache so the optimistic row doesn't linger.
await _evictCache(op.domain, op.targetId ?? op.tempId);
return true;
} on _OverwrittenSignal catch (s) {
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.overwritten,
domain: op.domain,
title: s.title,
));
return true;
} on AppException catch (e) {
// Auth or other — surface and drop so the queue can drain.
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.rejected,
domain: op.domain,
title: payload['title'] as String?,
detail: e.message,
));
return true;
}
}
Future<void> _evictCache(String domain, int? id) async {
if (id == null) return;
switch (domain) {
case kSyncDomainNotes:
await _db.deleteNote(id);
case kSyncDomainTasks:
await _db.deleteTask(id);
case kSyncDomainProjects:
await _db.deleteProject(id);
case kSyncDomainMilestones:
await _db.deleteMilestone(id);
case kSyncDomainEvents:
await _db.deleteEvent(id);
}
}
// ── Notes ──────────────────────────────────────────────────────────────────
Future<void> _replayNote(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final note = await _notesApi.create(
p['title'] as String? ?? '',
p['body'] as String? ?? '',
tags: _stringList(p['tags']),
projectId: p['project_id'] as int?,
noteType: p['note_type'] as String? ?? 'note',
);
if (op.tempId != null) await _db.deleteNote(op.tempId!);
await _db.upsertNote(note);
case kWriteVerbUpdate:
await _conflictGuard<Note>(
baseline: op.baselineUpdatedAt,
fetch: () => _notesApi.getOne(op.targetId!),
updatedAt: (n) => n.updatedAt,
title: (n) => n.title,
syncCache: (n) => _db.upsertNote(n),
);
final note = await _notesApi.update(
op.targetId!,
p['title'] as String? ?? '',
p['body'] as String? ?? '',
tags: _stringList(p['tags']),
projectId: p['project_id'] as int?,
clearProject: p['clear_project'] as bool? ?? false,
noteType: p['note_type'] as String? ?? 'note',
);
await _db.upsertNote(note);
case kWriteVerbDelete:
try {
await _notesApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteNote(op.targetId!);
}
}
// ── Tasks ──────────────────────────────────────────────────────────────────
Future<void> _replayTask(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final task = await _tasksApi.create(
title: p['title'] as String? ?? '',
description: p['description'] as String?,
status: TaskStatusExtension.fromString(p['status'] as String?),
priority: TaskPriorityExtension.fromString(p['priority'] as String?),
dueDate: _parseDate(p['due_date']),
projectId: p['project_id'] as int?,
parentId: p['parent_id'] as int?,
);
if (op.tempId != null) await _db.deleteTask(op.tempId!);
await _db.upsertTask(task);
case kWriteVerbUpdate:
await _conflictGuard<Task>(
baseline: op.baselineUpdatedAt,
fetch: () => _tasksApi.getOne(op.targetId!),
updatedAt: (t) => t.updatedAt,
title: (t) => t.title,
syncCache: (t) => _db.upsertTask(t),
);
final task = await _tasksApi.update(op.targetId!, p);
await _db.upsertTask(task);
case kWriteVerbDelete:
try {
await _tasksApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteTask(op.targetId!);
}
}
// ── Projects ───────────────────────────────────────────────────────────────
Future<void> _replayProject(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final project = await _projectsApi.create(
title: p['title'] as String? ?? '',
description: p['description'] as String?,
goal: p['goal'] as String?,
color: p['color'] as String?,
status: p['status'] as String? ?? 'active',
);
if (op.tempId != null) await _db.deleteProject(op.tempId!);
await _db.upsertProject(project);
case kWriteVerbUpdate:
await _conflictGuard<Project>(
baseline: op.baselineUpdatedAt,
fetch: () => _projectsApi.getOne(op.targetId!),
updatedAt: (proj) => proj.updatedAt,
title: (proj) => proj.title,
syncCache: (proj) => _db.upsertProject(proj),
);
final project = await _projectsApi.update(op.targetId!, p);
await _db.upsertProject(project);
case kWriteVerbDelete:
try {
await _projectsApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteProject(op.targetId!);
}
}
// ── Milestones ─────────────────────────────────────────────────────────────
Future<void> _replayMilestone(
PendingWrite op, Map<String, dynamic> p) async {
final projectId = p['project_id'] as int;
switch (op.verb) {
case kWriteVerbCreate:
final milestone = await _milestonesApi.create(
projectId,
title: p['title'] as String? ?? '',
description: p['description'] as String?,
orderIndex: p['order_index'] as int? ?? 0,
);
if (op.tempId != null) await _db.deleteMilestone(op.tempId!);
await _db.upsertMilestone(milestone);
case kWriteVerbUpdate:
// Milestones API has no getOne — skip the conflict pre-check and
// let the PUT apply unconditionally. If two clients fight over the
// same milestone, last-writer-wins is acceptable here.
final milestone = await _milestonesApi.update(
projectId,
op.targetId!,
Map<String, dynamic>.from(p)..remove('project_id'),
);
await _db.upsertMilestone(milestone);
case kWriteVerbDelete:
try {
await _milestonesApi.delete(projectId, op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteMilestone(op.targetId!);
}
}
// ── Events ─────────────────────────────────────────────────────────────────
Future<void> _replayEvent(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final event = await _eventsApi.createEvent(p);
if (op.tempId != null) await _db.deleteEvent(op.tempId!);
await _db.upsertEvent(event);
case kWriteVerbUpdate:
// Events API also has no getOne — skip the pre-check; last-writer-wins.
final event = await _eventsApi.updateEvent(op.targetId!, p);
await _db.upsertEvent(event);
case kWriteVerbDelete:
try {
await _eventsApi.deleteEvent(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteEvent(op.targetId!);
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
/// Server-wins conflict guard. Fetch the target, compare its `updated_at`
/// against the baseline captured when the user made the offline edit;
/// if the server is newer, sync cache and signal the caller to drop.
Future<void> _conflictGuard<T>({
required DateTime? baseline,
required Future<T> Function() fetch,
required DateTime Function(T) updatedAt,
required String Function(T) title,
required Future<void> Function(T) syncCache,
}) async {
if (baseline == null) return;
final server = await fetch();
if (updatedAt(server).isAfter(baseline)) {
await syncCache(server);
throw _OverwrittenSignal(title(server));
}
}
List<String> _stringList(dynamic raw) =>
raw is List ? raw.map((e) => e.toString()).toList() : const [];
DateTime? _parseDate(dynamic raw) {
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw);
return null;
}
}
class _OverwrittenSignal implements Exception {
final String title;
const _OverwrittenSignal(this.title);
}
+108 -8
View File
@@ -4,20 +4,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/api_client.dart';
import '../data/api/auth_api.dart';
import '../data/api/briefing_api.dart';
import '../data/api/chat_api.dart';
import '../data/api/journal_api.dart';
import '../data/api/knowledge_api.dart';
import '../data/api/voice_api.dart';
import '../data/api/milestones_api.dart';
import '../data/api/events_api.dart';
import '../data/api/notes_api.dart';
import '../data/api/projects_api.dart';
import '../data/api/quick_capture_api.dart';
import '../data/api/settings_api.dart';
import '../data/api/tasks_api.dart';
import '../data/local/database.dart';
import '../data/repositories/auth_repository.dart';
import '../data/repositories/chat_repository.dart';
import '../data/repositories/events_repository.dart';
import '../data/repositories/knowledge_repository.dart';
import '../data/repositories/voice_repository.dart';
import '../data/repositories/milestones_repository.dart';
import '../data/repositories/notes_repository.dart';
import '../data/repositories/projects_repository.dart';
import '../data/repositories/tasks_repository.dart';
import '../data/repositories/write_queue.dart';
import 'settings_provider.dart';
final cookieJarProvider = Provider<PersistCookieJar>((ref) {
@@ -55,24 +63,45 @@ final projectsApiProvider = Provider<ProjectsApi>((ref) {
return ProjectsApi(ref.watch(dioProvider));
});
/// Local SQLite cache used by repositories for read-through caching and
/// offline fallback. Backed by Drift; lives for the lifetime of the
/// ProviderScope and is closed automatically when the scope disposes.
final fabledDatabaseProvider = Provider<FabledDatabase>((ref) {
final db = FabledDatabase();
ref.onDispose(db.close);
return db;
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(ref.watch(authApiProvider));
});
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
return NotesRepository(ref.watch(notesApiProvider));
return NotesRepository(
ref.watch(notesApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
return TasksRepository(ref.watch(tasksApiProvider));
return TasksRepository(
ref.watch(tasksApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
return ChatRepository(ref.watch(chatApiProvider));
return ChatRepository(
ref.watch(chatApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
return ProjectsRepository(ref.watch(projectsApiProvider));
return ProjectsRepository(
ref.watch(projectsApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
@@ -80,13 +109,84 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
});
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
return MilestonesRepository(ref.watch(milestonesApiProvider));
return MilestonesRepository(
ref.watch(milestonesApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final briefingApiProvider = Provider<BriefingApi>((ref) {
return BriefingApi(ref.watch(dioProvider));
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
return KnowledgeApi(ref.watch(dioProvider));
});
final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
});
final journalApiProvider = Provider<JournalApi>((ref) {
return JournalApi(ref.watch(dioProvider));
});
final settingsApiProvider = Provider<SettingsApi>((ref) {
return SettingsApi(ref.watch(dioProvider));
});
final voiceApiProvider = Provider<VoiceApi>((ref) {
return VoiceApi(ref.watch(dioProvider));
});
final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
return VoiceRepository(ref.watch(voiceApiProvider));
});
final eventsApiProvider = Provider<EventsApi>((ref) {
return EventsApi(ref.watch(dioProvider));
});
final eventsRepositoryProvider = Provider<EventsRepository>((ref) {
return EventsRepository(
ref.watch(eventsApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
/// Phase 3 — drains the offline write queue. Listen on
/// `writeQueueFailuresProvider` to surface dropped ops to the user;
/// `writeQueueDepthProvider` powers the OfflineBanner's "Retry (N)" affordance.
final writeQueueProvider = Provider<WriteQueue>((ref) {
final queue = WriteQueue(
db: ref.watch(fabledDatabaseProvider),
notesApi: ref.watch(notesApiProvider),
tasksApi: ref.watch(tasksApiProvider),
projectsApi: ref.watch(projectsApiProvider),
milestonesApi: ref.watch(milestonesApiProvider),
eventsApi: ref.watch(eventsApiProvider),
);
ref.onDispose(queue.dispose);
return queue;
});
final writeQueueDepthProvider = StreamProvider<int>((ref) {
return ref.watch(fabledDatabaseProvider).watchQueueDepth();
});
final writeQueueFailuresProvider = StreamProvider<QueueFailure>((ref) {
return ref.watch(writeQueueProvider).failures;
});
/// Per-domain set of ids with a queued write (target_id temp_id). Phase 4
/// — used by `PendingSyncBadge` in list views to mark rows that are still
/// in flight.
final pendingNoteIdsProvider = StreamProvider<Set<int>>((ref) {
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainNotes);
});
final pendingTaskIdsProvider = StreamProvider<Set<int>>((ref) {
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainTasks);
});
final pendingProjectIdsProvider = StreamProvider<Set<int>>((ref) {
return ref
.watch(fabledDatabaseProvider)
.watchPendingIds(kSyncDomainProjects);
});
+9 -1
View File
@@ -1,8 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart';
import 'api_client_provider.dart';
import 'settings_provider.dart';
enum AuthStatus { unknown, authenticated, unauthenticated }
enum AuthStatus { unknown, authenticated, unauthenticated, offline }
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
@@ -14,7 +16,12 @@ class AuthNotifier extends Notifier<AuthStatus> {
try {
final repo = ref.read(authRepositoryProvider);
final ok = await repo.verify();
if (ok) {
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
}
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
} on NetworkException {
state = AuthStatus.offline;
} catch (_) {
state = AuthStatus.unauthenticated;
}
@@ -23,6 +30,7 @@ class AuthNotifier extends Notifier<AuthStatus> {
Future<void> login(String username, String password) async {
final repo = ref.read(authRepositoryProvider);
await repo.login(username, password);
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
state = AuthStatus.authenticated;
}
-137
View File
@@ -1,137 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/briefing_conversation.dart';
import '../data/models/message.dart';
import 'api_client_provider.dart';
/// Drives the loading indicator in BriefingScreen's reply area.
final isBriefingStreamingProvider =
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
class _BoolNotifier extends Notifier<bool> {
@override
bool build() => false;
}
final briefingProvider =
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
BriefingNotifier.new);
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
@override
Future<BriefingConversation> build() async {
return ref.read(briefingApiProvider).getToday();
}
/// Silently fetch the latest briefing and patch state without triggering
/// AsyncLoading — existing content stays visible while the fetch is in flight.
Future<void> silentRefresh() async {
final current = state.value;
if (current == null) return;
try {
final fresh = await ref.read(briefingApiProvider).getToday();
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
if (fresh.messages.length != current.messages.length ||
newLast?.content != curLast?.content) {
state = AsyncData(fresh);
}
} catch (_) {
// Network hiccup — silently ignore, keep existing content
}
}
/// Trigger a briefing slot (e.g. "compilation") then reload.
Future<void> refresh(String slot) async {
await ref.read(briefingApiProvider).triggerSlot(slot);
ref.invalidateSelf();
await future;
}
/// Send a reply to today's briefing conversation.
///
/// Mirrors MessagesNotifier.sendMessage():
/// 1. Optimistic UI update
/// 2. POST message to chat endpoint
/// 3. SSE stream (best-effort)
/// 4. Poll until complete
Future<void> sendReply(String content) async {
final conv = state.value;
if (conv == null) return;
final convId = conv.id;
final chatApi = ref.read(chatApiProvider);
final previous = conv.messages;
final userMsg = Message(
conversationId: convId,
role: MessageRole.user,
content: content,
);
final placeholder = Message(
conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true;
try {
await chatApi.sendMessage(convId, content);
} catch (e) {
state = AsyncData(conv.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false;
rethrow;
}
// SSE stream (best-effort)
bool streamedContent = false;
try {
await for (final chunk in chatApi.streamGeneration(convId)) {
streamedContent = true;
final current = state.value;
if (current == null) break;
final msgs = current.messages;
if (msgs.isEmpty) continue;
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
}
} catch (_) {
// Fall through to polling.
}
// Poll until complete (max 20 attempts, 2s apart)
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await ref.read(briefingApiProvider).getMessages(convId);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final hasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh));
}
if (done) break;
}
} catch (_) {
// Clear the generating placeholder so UI doesn't spin forever.
final current = state.value;
if (current != null) {
final msgs = current.messages;
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData(current.copyWith(messages: [
...msgs.sublist(0, msgs.length - 1),
msgs.last.copyWith(status: 'complete'),
]));
}
}
} finally {
ref.read(isBriefingStreamingProvider.notifier).state = false;
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/calendar_event.dart';
import 'api_client_provider.dart';
// ─── CalendarState ────────────────────────────────────────────────────────────
class CalendarState {
final Map<DateTime, List<CalendarEvent>> eventsByDay;
final DateTime selectedDay;
final DateTime focusedMonth;
final DateTimeRange loadedRange;
const CalendarState({
required this.eventsByDay,
required this.selectedDay,
required this.focusedMonth,
required this.loadedRange,
});
CalendarState copyWith({
Map<DateTime, List<CalendarEvent>>? eventsByDay,
DateTime? selectedDay,
DateTime? focusedMonth,
DateTimeRange? loadedRange,
}) {
return CalendarState(
eventsByDay: eventsByDay ?? this.eventsByDay,
selectedDay: selectedDay ?? this.selectedDay,
focusedMonth: focusedMonth ?? this.focusedMonth,
loadedRange: loadedRange ?? this.loadedRange,
);
}
}
// ─── CalendarNotifier ─────────────────────────────────────────────────────────
final calendarProvider =
AsyncNotifierProvider<CalendarNotifier, CalendarState>(CalendarNotifier.new);
class CalendarNotifier extends AsyncNotifier<CalendarState> {
@override
Future<CalendarState> build() async {
final now = DateTime.now();
final today = dateOnly(now);
// Fetch current month ± 1 month as the initial window.
final from = DateTime(now.year, now.month - 1, 1);
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
final events = await ref.watch(eventsRepositoryProvider).getEvents(from, to);
return CalendarState(
eventsByDay: _groupByDay(events),
selectedDay: today,
focusedMonth: DateTime(now.year, now.month),
loadedRange: DateTimeRange(start: from, end: to),
);
}
/// Re-fetch events for the current range without clearing state (no flicker).
Future<void> refresh() async {
final current = state.value;
if (current == null) return;
final events = await ref.read(eventsRepositoryProvider).getEvents(
current.loadedRange.start,
current.loadedRange.end,
);
state = AsyncData(current.copyWith(eventsByDay: _groupByDay(events)));
}
/// Synchronously updates selectedDay and focusedMonth. No API call.
void selectDay(DateTime day) {
final current = state.value;
if (current == null) return;
state = AsyncData(current.copyWith(
selectedDay: dateOnly(day),
focusedMonth: DateTime(day.year, day.month),
));
}
/// Updates focusedMonth. Fetches events for [month] if not already loaded.
Future<void> loadMonth(DateTime month) async {
final current = state.value;
if (current == null) return;
final focused = DateTime(month.year, month.month);
// Always update focusedMonth so TableCalendar shows the right page.
state = AsyncData(current.copyWith(focusedMonth: focused));
// Skip fetch if the first day of [month] is within the already-loaded range.
final monthStart = DateTime(month.year, month.month, 1);
if (!monthStart.isBefore(current.loadedRange.start) &&
!monthStart.isAfter(current.loadedRange.end)) {
return;
}
try {
final from = DateTime(month.year, month.month, 1);
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
final events = await ref.read(eventsRepositoryProvider).getEvents(from, to);
final s = state.value!;
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
for (final e in events) {
final key = dateOnly(e.startDt);
(merged[key] ??= []).add(e);
}
final newStart =
from.isBefore(s.loadedRange.start) ? from : s.loadedRange.start;
final newEnd =
to.isAfter(s.loadedRange.end) ? to : s.loadedRange.end;
state = AsyncData(s.copyWith(
eventsByDay: merged,
loadedRange: DateTimeRange(start: newStart, end: newEnd),
));
} catch (_) {
// Failures are silent — already-loaded months remain visible.
}
}
/// Inserts [event] into eventsByDay after a successful createEvent API call.
void addEvent(CalendarEvent event) {
final current = state.value;
if (current == null) return;
final key = dateOnly(event.startDt);
final updated =
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
(updated[key] ??= []).add(event);
state = AsyncData(current.copyWith(eventsByDay: updated));
}
/// Replaces the old entry for [updated.id] with [updated] after a successful
/// updateEvent API call. Scans all buckets to handle date changes.
void updateEvent(CalendarEvent updated) {
final current = state.value;
if (current == null) return;
final byDay =
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
for (final key in byDay.keys) {
byDay[key] = byDay[key]!.where((e) => e.id != updated.id).toList();
}
final newKey = dateOnly(updated.startDt);
(byDay[newKey] ??= []).add(updated);
state = AsyncData(current.copyWith(eventsByDay: byDay));
}
/// Removes event [id] from [date]'s bucket after a successful deleteEvent
/// API call.
void removeEvent(int id, DateTime date) {
final current = state.value;
if (current == null) return;
final key = dateOnly(date);
final byDay =
Map<DateTime, List<CalendarEvent>>.from(current.eventsByDay);
byDay[key] = (byDay[key] ?? []).where((e) => e.id != id).toList();
state = AsyncData(current.copyWith(eventsByDay: byDay));
}
}
Map<DateTime, List<CalendarEvent>> _groupByDay(List<CalendarEvent> events) {
final byDay = <DateTime, List<CalendarEvent>>{};
for (final e in events) {
final key = dateOnly(e.startDt);
(byDay[key] ??= []).add(e);
}
return byDay;
}
+16 -32
View File
@@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart';
import 'api_client_provider.dart';
import 'capture_queue_provider.dart';
import 'notes_provider.dart';
import 'tasks_provider.dart';
import 'chat_provider.dart';
/// Outcome of a single capture attempt — consumed by the UI for snackbars.
class CaptureResult {
@@ -25,7 +24,6 @@ class _CaptureResultNotifier extends Notifier<CaptureResult?> {
}
/// In-memory sequential work queue for quick captures.
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
final captureWorkQueueProvider =
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
CaptureWorkQueueNotifier.new,
@@ -52,31 +50,24 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
// Signal "no result yet" so the same result value can re-trigger watch.
ref.read(captureResultProvider.notifier).state = null;
try {
final api = ref.read(quickCaptureApiProvider);
final result = await api.capture(text);
// Create a new conversation, add it to the conversations list, then
// send the message and kick off generation in the background.
final conv =
await ref.read(conversationsProvider.notifier).create('');
final chatRepo = ref.read(chatRepositoryProvider);
await chatRepo.sendMessage(conv.id, text);
// Fire-and-forget: drain the SSE stream so the server generates a
// response (creating notes/tasks/etc.) without blocking the UI.
chatRepo.streamGeneration(conv.id).drain<void>().ignore();
// Dequeue on success.
state = state.length > 1 ? state.sublist(1) : [];
// Invalidate content providers so lists refresh.
switch (result.type) {
case 'note':
ref.invalidate(notesProvider);
case 'task':
case 'todo':
ref.invalidate(tasksProvider);
}
// Publish result for snackbar.
final msg = result.message.isNotEmpty
? result.message
: '${_typeLabel(result.type)} created: ${result.title}';
ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
ref.read(captureResultProvider.notifier).state =
const CaptureResult('Sent to Fabled.');
} on NetworkException catch (_) {
// Persist to offline queue and stop draining — still offline.
await ref.read(captureQueueProvider.notifier).enqueue(text);
state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state = CaptureResult(
ref.read(captureResultProvider.notifier).state = const CaptureResult(
"You're offline — capture saved and will retry automatically.",
);
break;
@@ -86,20 +77,13 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
CaptureResult(e.message, isError: true);
} catch (_) {
state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state =
CaptureResult('Capture failed. Please try again.', isError: true);
ref.read(captureResultProvider.notifier).state = const CaptureResult(
'Failed to send. Please try again.',
isError: true);
}
}
} finally {
_running = false;
}
}
String _typeLabel(String type) => switch (type) {
'note' => 'Note',
'task' => 'Task',
'event' => 'Event',
'todo' => 'To-do',
_ => type,
};
}
+202 -6
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/conversation.dart';
import '../data/models/message.dart';
import '../data/repositories/chat_repository.dart';
import 'api_client_provider.dart';
// Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops.
@@ -18,6 +21,20 @@ class _IsStreamingNotifier extends Notifier<bool> {
bool build() => false;
}
// Tracks the current tool status text during generation (empty = no status).
final streamingStatusProvider =
NotifierProvider.family<_StreamingStatusNotifier, String, int>(
(convId) => _StreamingStatusNotifier(convId),
);
class _StreamingStatusNotifier extends Notifier<String> {
// ignore: avoid_unused_constructor_parameters
_StreamingStatusNotifier(int convId);
@override
String build() => '';
}
final conversationsProvider =
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
ConversationsNotifier.new);
@@ -43,6 +60,12 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
]);
}
/// Re-fetch conversations without clearing the current list (no flicker).
Future<void> refresh() async {
final fresh = await ref.read(chatRepositoryProvider).getConversations();
state = AsyncData(fresh);
}
// Called after a message is sent to patch the server-generated title
// in-place without triggering a full reload or loading state.
void patchConversation(Conversation updated) {
@@ -71,6 +94,144 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
return messages;
}
/// Re-fetch messages without clearing the current list (no flicker).
///
/// Also unfreezes the UI if streaming state got stuck true — this happens
/// when an SSE connection dies silently (mobile network handoff, app
/// backgrounded mid-stream, reverse proxy dropping idle sockets) and the
/// send loop never observes a close. If the server-side message is already
/// done, we clear `isStreamingProvider` so the input unlocks.
Future<void> refresh() async {
final (_, messages) =
await ref.read(chatRepositoryProvider).getMessages(_convId);
state = AsyncData(messages);
Message? lastAssistant;
for (var i = messages.length - 1; i >= 0; i--) {
if (messages[i].role == MessageRole.assistant) {
lastAssistant = messages[i];
break;
}
}
if (lastAssistant != null && lastAssistant.status != 'generating') {
ref.read(isStreamingProvider(_convId).notifier).state = false;
ref.read(streamingStatusProvider(_convId).notifier).state = '';
}
}
/// Attach to an already-running generation for this conversation.
///
/// Used when the chat screen lands on a conversation that was started by
/// something other than a direct user message — e.g. the /news discuss
/// button, which creates a conversation on the backend and auto-kicks a
/// generation before navigating. Without this the stream runs to
/// completion invisibly and the screen only shows the final persisted
/// message after a manual refresh.
///
/// Safe to call unconditionally on screen init: no-ops when there is no
/// generating assistant message. Mirrors the web chat store's
/// reconnectIfGenerating() helper.
Future<void> attachToGeneration() async {
final convId = _convId;
final repo = ref.read(chatRepositoryProvider);
// Make sure we're looking at fresh server state before deciding whether
// to attach. The provider's build() fetches once; if the conversation
// was seeded via a POST that happened between build() and this call the
// generating placeholder won't be in our in-memory list yet.
try {
final (_, fresh) = await repo.getMessages(convId);
state = AsyncData(fresh);
} catch (_) {
// If we can't load messages we can't attach either — bail cleanly.
return;
}
if (ref.read(isStreamingProvider(convId))) return;
final msgs = state.value ?? const <Message>[];
final hasGeneratingAssistant = msgs.any(
(m) => m.role == MessageRole.assistant && m.status == 'generating',
);
if (!hasGeneratingAssistant) return;
ref.read(isStreamingProvider(convId).notifier).state = true;
const stallTimeout = Duration(seconds: 45);
bool streamedContent = false;
final iter = StreamIterator(repo.streamGeneration(convId));
try {
while (await iter.moveNext().timeout(stallTimeout)) {
final event = iter.current;
if (event is ChatTextChunk) {
streamedContent = true;
ref.read(streamingStatusProvider(convId).notifier).state = '';
final cur = state.requireValue;
if (cur.isEmpty) continue;
// Route text chunks into the generating assistant message. The
// last message is usually the placeholder, but tool-call fan-in
// means we can't rely on that universally.
final idx = _findGeneratingAssistantIndex(cur);
if (idx < 0) continue;
final updated = cur[idx].copyWith(content: cur[idx].content + event.text);
state = AsyncData([
...cur.sublist(0, idx),
updated,
...cur.sublist(idx + 1),
]);
} else if (event is ChatStatusUpdate) {
ref.read(streamingStatusProvider(convId).notifier).state = event.status;
} else if (event is ChatToolCall) {
final cur = state.requireValue;
if (cur.isEmpty) continue;
final idx = _findGeneratingAssistantIndex(cur);
if (idx < 0) continue;
final nextCalls = [...?cur[idx].toolCalls, event.toolCall];
final updated = cur[idx].copyWith(toolCalls: nextCalls);
state = AsyncData([
...cur.sublist(0, idx),
updated,
...cur.sublist(idx + 1),
]);
}
}
} on TimeoutException {
// Stall — fall through to polling.
} catch (_) {
// Stream failed — fall through to polling.
} finally {
await iter.cancel();
}
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final (_, fresh) = await repo.getMessages(convId);
final done = fresh.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final polledHasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
if (!streamedContent || done || polledHasContent) {
state = AsyncData(fresh);
}
if (done) break;
}
} catch (_) {
// Give up silently — user can pull-to-refresh.
} finally {
ref.read(isStreamingProvider(convId).notifier).state = false;
ref.read(streamingStatusProvider(convId).notifier).state = '';
}
}
int _findGeneratingAssistantIndex(List<Message> msgs) {
for (var i = msgs.length - 1; i >= 0; i--) {
final m = msgs[i];
if (m.role == MessageRole.assistant && m.status == 'generating') return i;
}
return -1;
}
Future<void> sendMessage(String content) async {
final convId = _convId;
final repo = ref.read(chatRepositoryProvider);
@@ -101,17 +262,51 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
}
// ── Step 2: Stream the response (best effort — silent on failure). ──
//
// We use a StreamIterator with a per-event timeout as a stall watchdog.
// Mobile networks occasionally drop SSE sockets silently: the TCP
// connection is half-closed, Dio never sees the close, and `await for`
// hangs forever with `isStreaming=true`, freezing the input. If no
// event arrives within the watchdog window we bail out and let the
// polling pass below reconcile state from the server.
const stallTimeout = Duration(seconds: 45);
bool streamedContent = false;
final iter = StreamIterator(repo.streamGeneration(convId));
try {
await for (final chunk in repo.streamGeneration(convId)) {
streamedContent = true;
final msgs = state.requireValue;
if (msgs.isEmpty) continue;
final updated = msgs.last.copyWith(content: msgs.last.content + chunk);
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
while (await iter.moveNext().timeout(stallTimeout)) {
final event = iter.current;
if (event is ChatTextChunk) {
streamedContent = true;
ref.read(streamingStatusProvider(convId).notifier).state = '';
final msgs = state.requireValue;
if (msgs.isEmpty) continue;
final updated =
msgs.last.copyWith(content: msgs.last.content + event.text);
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
} else if (event is ChatStatusUpdate) {
ref.read(streamingStatusProvider(convId).notifier).state =
event.status;
} else if (event is ChatToolCall) {
// Append the tool call to the in-flight assistant message so the
// chip appears live. The reload pass at the end of this function
// will overwrite with the persisted version, which carries the
// same shape — no de-dup needed.
final msgs = state.requireValue;
if (msgs.isEmpty) continue;
final last = msgs.last;
if (last.role != MessageRole.assistant) continue;
final nextCalls = [...?last.toolCalls, event.toolCall];
final updated = last.copyWith(toolCalls: nextCalls);
state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]);
}
}
} on TimeoutException {
// Stall watchdog — no SSE event for stallTimeout. Fall through to
// polling so the UI eventually unfreezes even if the socket is dead.
} catch (_) {
// SSE failed — fall through to the polling reload below.
} finally {
await iter.cancel();
}
// ── Step 3: Poll the API until we have a completed assistant response.
@@ -157,6 +352,7 @@ class MessagesNotifier extends AsyncNotifier<List<Message>> {
}
} finally {
ref.read(isStreamingProvider(convId).notifier).state = false;
ref.read(streamingStatusProvider(convId).notifier).state = '';
}
}
}
+212
View File
@@ -0,0 +1,212 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/chat_api.dart';
import '../data/models/journal_day.dart';
import '../data/models/message.dart';
import 'api_client_provider.dart';
/// Drives the loading indicator in JournalScreen's reply area.
final isJournalStreamingProvider =
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
class _BoolNotifier extends Notifier<bool> {
@override
bool build() => false;
}
final journalProvider =
AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
class JournalNotifier extends AsyncNotifier<JournalDay> {
@override
Future<JournalDay> build() async {
return ref.read(journalApiProvider).getToday();
}
/// Silently fetch today's journal and patch state without triggering
/// AsyncLoading — existing content stays visible while the fetch is in flight.
Future<void> silentRefresh() async {
final current = state.value;
if (current == null) return;
try {
final fresh = await ref.read(journalApiProvider).getToday();
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
if (fresh.messages.length != current.messages.length ||
newLast?.content != curLast?.content) {
state = AsyncData(fresh);
}
} catch (_) {
// Network hiccup — silently ignore, keep existing content.
}
}
/// Force-regenerate today's daily prep then reload.
Future<void> regeneratePrep() async {
await ref.read(journalApiProvider).triggerPrep();
ref.invalidateSelf();
await future;
}
/// Re-fetch today's journal and unfreeze a stuck streaming state if the
/// server-side message is already complete.
///
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
/// socket dies silently the send loop never observes close and
/// [isJournalStreamingProvider] stays stuck true. This is the manual
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and
/// the lifecycle-resume hook.
Future<void> refreshMessages() async {
final current = state.value;
if (current == null) {
ref.invalidateSelf();
return;
}
try {
final fresh = await ref.read(journalApiProvider).getToday();
state = AsyncData(fresh);
final messages = fresh.messages;
Message? lastAssistant;
for (var i = messages.length - 1; i >= 0; i--) {
if (messages[i].role == MessageRole.assistant) {
lastAssistant = messages[i];
break;
}
}
if (lastAssistant != null && lastAssistant.status != 'generating') {
ref.read(isJournalStreamingProvider.notifier).state = false;
}
} catch (_) {
// Network hiccup — keep existing state; user can retry.
}
}
/// Send a reply to today's journal conversation.
///
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
/// stall-watchdog pattern:
/// 1. Optimistic UI update
/// 2. POST message to chat endpoint
/// 3. SSE stream with per-event timeout (stall watchdog)
/// 4. Poll until complete
Future<void> sendReply(String content) async {
final day = state.value;
if (day == null) return;
final conv = day.conversation;
if (conv == null) return;
final convId = conv.id;
final chatApi = ref.read(chatApiProvider);
final previous = day.messages;
final userMsg = Message(
conversationId: convId,
role: MessageRole.user,
content: content,
);
final placeholder = Message(
conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isJournalStreamingProvider.notifier).state = true;
try {
await chatApi.sendMessage(convId, content);
} catch (e) {
state = AsyncData(day.copyWith(messages: previous));
ref.read(isJournalStreamingProvider.notifier).state = false;
rethrow;
}
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
await _pollUntilComplete(convId, streamedContent);
}
/// Consume an SSE stream into the current journal day state.
///
/// Uses a StreamIterator with a per-event timeout as a stall watchdog —
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
/// Mobile networks occasionally drop SSE sockets silently. If no event
/// arrives within the watchdog window we bail out and let polling
/// reconcile state from the server.
///
/// Returns whether any text content was actually streamed.
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
const stallTimeout = Duration(seconds: 45);
bool streamedContent = false;
final iter = StreamIterator(stream);
try {
while (await iter.moveNext().timeout(stallTimeout)) {
final event = iter.current;
final current = state.value;
if (current == null) break;
final msgs = current.messages;
if (msgs.isEmpty) continue;
if (event is ChatTextChunk) {
streamedContent = true;
final updated =
msgs.last.copyWith(content: msgs.last.content + event.text);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
} else if (event is ChatToolCall) {
final last = msgs.last;
if (last.role != MessageRole.assistant) continue;
final nextCalls = [...?last.toolCalls, event.toolCall];
final updated = last.copyWith(toolCalls: nextCalls);
state = AsyncData(current.copyWith(
messages: [...msgs.sublist(0, msgs.length - 1), updated]));
}
}
} on TimeoutException {
// Stall watchdog — no SSE event for stallTimeout. Fall through to
// polling so the UI eventually unfreezes even if the socket is dead.
} catch (_) {
// SSE failed — fall through to polling.
} finally {
await iter.cancel();
}
return streamedContent;
}
/// Poll today's journal until the last assistant row is complete. Always
/// clears [isJournalStreamingProvider] at the end so the input can't stay
/// locked.
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
final journalApi = ref.read(journalApiProvider);
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await journalApi.getToday();
final freshMsgs = fresh.messages;
final done = freshMsgs.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final hasContent = freshMsgs.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: freshMsgs));
}
if (done) break;
}
} catch (_) {
final current = state.value;
if (current != null) {
final msgs = current.messages;
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData(current.copyWith(messages: [
...msgs.sublist(0, msgs.length - 1),
msgs.last.copyWith(status: 'complete'),
]));
}
}
} finally {
ref.read(isJournalStreamingProvider.notifier).state = false;
}
}
}
+273
View File
@@ -0,0 +1,273 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/knowledge_item.dart';
import '../data/repositories/knowledge_repository.dart';
import 'api_client_provider.dart';
export '../data/models/knowledge_item.dart';
/// Immutable state for the Knowledge screen's two-tier paginated feed.
class KnowledgeState {
final List<int> ids;
final Map<int, KnowledgeItem> items;
final int totalIds;
final bool isLoadingIds;
final bool isLoadingBatch;
final bool hasMore;
final String? noteType; // null = All
final List<String> activeTags;
final String? searchQuery;
final Map<String, int> counts;
final List<String> availableTags;
final String? error;
const KnowledgeState({
this.ids = const [],
this.items = const {},
this.totalIds = 0,
this.isLoadingIds = false,
this.isLoadingBatch = false,
this.hasMore = false,
this.noteType,
this.activeTags = const [],
this.searchQuery,
this.counts = const {},
this.availableTags = const [],
this.error,
});
/// Items in server-defined ID order, only those already hydrated.
List<KnowledgeItem> get orderedItems =>
ids.where(items.containsKey).map((id) => items[id]!).toList();
/// IDs that have been fetched but not yet hydrated.
List<int> get unhydratedIds =>
ids.where((id) => !items.containsKey(id)).toList();
KnowledgeState copyWith({
List<int>? ids,
Map<int, KnowledgeItem>? items,
int? totalIds,
bool? isLoadingIds,
bool? isLoadingBatch,
bool? hasMore,
Object? noteType = _keep,
List<String>? activeTags,
Object? searchQuery = _keep,
Map<String, int>? counts,
List<String>? availableTags,
Object? error = _keep,
}) =>
KnowledgeState(
ids: ids ?? this.ids,
items: items ?? this.items,
totalIds: totalIds ?? this.totalIds,
isLoadingIds: isLoadingIds ?? this.isLoadingIds,
isLoadingBatch: isLoadingBatch ?? this.isLoadingBatch,
hasMore: hasMore ?? this.hasMore,
noteType:
identical(noteType, _keep) ? this.noteType : noteType as String?,
activeTags: activeTags ?? this.activeTags,
searchQuery: identical(searchQuery, _keep)
? this.searchQuery
: searchQuery as String?,
counts: counts ?? this.counts,
availableTags: availableTags ?? this.availableTags,
error: identical(error, _keep) ? this.error : error as String?,
);
static const _keep = Object();
}
class KnowledgeNotifier extends Notifier<KnowledgeState> {
@override
KnowledgeState build() => const KnowledgeState();
KnowledgeRepository get _repo => ref.read(knowledgeRepositoryProvider);
// ── Filter setters — each resets and re-fetches ──────────────────────────
Future<void> setTypeFilter(String? noteType) async {
state = KnowledgeState(noteType: noteType, activeTags: state.activeTags);
await _fetchFromScratch();
}
Future<void> toggleTag(String tag) async {
final tags = state.activeTags.contains(tag)
? state.activeTags.where((t) => t != tag).toList()
: [...state.activeTags, tag];
state = KnowledgeState(
noteType: state.noteType,
activeTags: tags,
searchQuery: state.searchQuery,
);
await _fetchFromScratch();
}
Future<void> setSearch(String? q) async {
final query = (q?.trim().isEmpty ?? true) ? null : q?.trim();
state = KnowledgeState(
noteType: state.noteType,
activeTags: state.activeTags,
searchQuery: query,
);
await _fetchFromScratch();
}
Future<void> refresh() async {
// Keep current items visible during re-fetch (no flicker).
try {
if (state.noteType == 'task') {
final tasks = await ref.read(tasksApiProvider).getAll();
final items = {
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
};
state = state.copyWith(
ids: tasks.map((t) => t.id).toList(),
items: items,
totalIds: tasks.length,
hasMore: false,
);
await _loadCounts();
return;
}
final (ids, total) = await _repo.fetchIds(
noteType: state.noteType,
tags: state.activeTags,
q: state.searchQuery,
limit: 50,
offset: 0,
);
// Hydrate the new IDs
final batch = await _repo.fetchBatch(ids);
final freshItems = {for (final item in batch) item.id: item};
state = state.copyWith(
ids: ids,
items: freshItems,
totalIds: total,
hasMore: ids.length < total,
);
await Future.wait([_loadCounts(), _loadTags()]);
} catch (_) {
// Silent — stale data is better than an error on refresh
}
}
// ── Scroll-triggered loaders ─────────────────────────────────────────────
/// Hydrate the next 12 un-hydrated IDs. Call when approaching scroll end.
Future<void> hydrateNext() async {
if (state.isLoadingBatch) return;
final toFetch = state.unhydratedIds.take(12).toList();
if (toFetch.isEmpty) {
// All fetched IDs are hydrated — try loading more IDs.
if (state.hasMore && !state.isLoadingIds) await _loadMoreIds();
return;
}
state = state.copyWith(isLoadingBatch: true);
try {
final fetched = await _repo.fetchBatch(toFetch);
final updated = Map<int, KnowledgeItem>.from(state.items);
for (final item in fetched) {
updated[item.id] = item;
}
state = state.copyWith(items: updated, isLoadingBatch: false);
} catch (_) {
state = state.copyWith(isLoadingBatch: false);
}
}
// ── Private helpers ──────────────────────────────────────────────────────
Future<void> _fetchFromScratch() async {
state = state.copyWith(isLoadingIds: true, error: null);
try {
if (state.noteType == 'task') {
// Tasks live under /api/tasks — fetch all and convert directly.
final tasks = await ref.read(tasksApiProvider).getAll();
final items = {
for (final t in tasks) t.id: KnowledgeItem.fromTask(t),
};
state = state.copyWith(
ids: tasks.map((t) => t.id).toList(),
items: items,
totalIds: tasks.length,
isLoadingIds: false,
hasMore: false,
);
await _loadCounts();
return;
}
final (ids, total) = await _repo.fetchIds(
noteType: state.noteType,
tags: state.activeTags,
q: state.searchQuery,
limit: 50,
offset: 0,
);
state = state.copyWith(
ids: ids,
items: {},
totalIds: total,
isLoadingIds: false,
hasMore: ids.length < total,
);
// Load counts and tags in parallel with the first batch hydration.
await Future.wait([
hydrateNext(),
_loadCounts(),
_loadTags(),
]);
} catch (e) {
state = state.copyWith(
isLoadingIds: false,
error: e.toString(),
);
}
}
Future<void> _loadMoreIds() async {
if (!state.hasMore || state.isLoadingIds) return;
state = state.copyWith(isLoadingIds: true);
try {
final (newIds, total) = await _repo.fetchIds(
noteType: state.noteType,
tags: state.activeTags,
q: state.searchQuery,
limit: 50,
offset: state.ids.length,
);
final combined = [...state.ids, ...newIds];
state = state.copyWith(
ids: combined,
totalIds: total,
isLoadingIds: false,
hasMore: combined.length < total,
);
// Hydrate the newly fetched IDs immediately — the user is
// already at the scroll bottom so _onScroll won't re-fire.
await hydrateNext();
} catch (_) {
state = state.copyWith(isLoadingIds: false);
}
}
Future<void> _loadCounts() async {
try {
final counts = await _repo.fetchCounts(tags: state.activeTags);
state = state.copyWith(counts: counts);
} catch (_) {}
}
Future<void> _loadTags() async {
try {
final tags = await _repo.fetchTags(noteType: state.noteType);
state = state.copyWith(availableTags: tags);
} catch (_) {}
}
}
final knowledgeProvider =
NotifierProvider<KnowledgeNotifier, KnowledgeState>(KnowledgeNotifier.new);
+9 -3
View File
@@ -17,10 +17,14 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
String body, {
List<String> tags = const [],
int? projectId,
String noteType = 'note',
}) async {
final note = await ref
.read(notesRepositoryProvider)
.create(title, body, tags: tags, projectId: projectId);
final note = await ref.read(notesRepositoryProvider).create(
title, body,
tags: tags,
projectId: projectId,
noteType: noteType,
);
state = AsyncData([...state.value ?? [], note]);
return note;
}
@@ -32,6 +36,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
List<String> tags = const [],
int? projectId,
bool clearProject = false,
String noteType = 'note',
}) async {
final updated = await ref.read(notesRepositoryProvider).update(
id,
@@ -40,6 +45,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
tags: tags,
projectId: projectId,
clearProject: clearProject,
noteType: noteType,
);
state = AsyncData([
for (final n in state.value ?? [])
+9 -1
View File
@@ -10,7 +10,15 @@ final projectsProvider =
class ProjectsNotifier extends AsyncNotifier<List<Project>> {
@override
Future<List<Project>> build() async {
return ref.watch(projectsRepositoryProvider).getAll();
return ref
.watch(projectsRepositoryProvider)
.getAll(sort: 'updated_at', order: 'desc');
}
Future<void> refresh() async {
final fresh = await ref.read(projectsRepositoryProvider)
.getAll(sort: 'updated_at', order: 'desc');
state = AsyncData(fresh);
}
Future<Project> create({
+47
View File
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_client_provider.dart';
const _kServerUrl = 'server_url';
const _kThemeMode = 'theme_mode';
const _kForgejoRepoUrl = 'forgejo_repo_url';
const _kHasEverLoggedIn = 'has_ever_logged_in';
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Override in ProviderScope');
@@ -82,3 +85,47 @@ class ServerUrlNotifier extends Notifier<String?> {
state = null;
}
}
/// Tracks whether this install has ever completed a successful login.
/// Used so offline users who've logged in before land on the briefing (with a
/// banner) instead of being dumped onto the login screen as if freshly installed.
final hasEverLoggedInProvider =
NotifierProvider<HasEverLoggedInNotifier, bool>(HasEverLoggedInNotifier.new);
class HasEverLoggedInNotifier extends Notifier<bool> {
@override
bool build() {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getBool(_kHasEverLoggedIn) ?? false;
}
Future<void> markLoggedIn() async {
if (state) return;
await ref.read(sharedPreferencesProvider).setBool(_kHasEverLoggedIn, true);
state = true;
}
Future<void> clear() async {
await ref.read(sharedPreferencesProvider).remove(_kHasEverLoggedIn);
state = false;
}
}
final serverSettingsProvider =
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
ServerSettingsNotifier.new);
class ServerSettingsNotifier extends AsyncNotifier<Map<String, dynamic>> {
@override
Future<Map<String, dynamic>> build() async {
try {
return await ref.read(settingsApiProvider).getAll();
} catch (_) {
return {};
}
}
Future<void> refresh() async {
state = AsyncData(await ref.read(settingsApiProvider).getAll());
}
}
+5
View File
@@ -17,6 +17,11 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
return ref.watch(tasksRepositoryProvider).getAll();
}
Future<void> refresh() async {
final fresh = await ref.read(tasksRepositoryProvider).getAll();
state = AsyncData(fresh);
}
Future<Task> create({
required String title,
String? description,
+86 -32
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_file/open_file.dart';
@@ -5,7 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
enum UpdateStatus {
idle,
checking,
downloading,
readyToInstall,
upToDate,
error,
}
class UpdateState {
final UpdateStatus status;
@@ -14,6 +23,7 @@ class UpdateState {
final String? downloadUrl;
final double downloadProgress;
final String? errorMessage;
final String? apkPath;
const UpdateState({
this.status = UpdateStatus.idle,
@@ -22,6 +32,7 @@ class UpdateState {
this.downloadUrl,
this.downloadProgress = 0.0,
this.errorMessage,
this.apkPath,
});
UpdateState copyWith({
@@ -31,6 +42,7 @@ class UpdateState {
String? downloadUrl,
double? downloadProgress,
String? errorMessage,
String? apkPath,
}) =>
UpdateState(
status: status ?? this.status,
@@ -39,6 +51,7 @@ class UpdateState {
downloadUrl: downloadUrl ?? this.downloadUrl,
downloadProgress: downloadProgress ?? this.downloadProgress,
errorMessage: errorMessage ?? this.errorMessage,
apkPath: apkPath ?? this.apkPath,
);
}
@@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier<UpdateState> {
@override
UpdateState build() => const UpdateState();
/// [repoUrl] is the Forgejo repo page URL, e.g.
/// "https://git.example.com/user/fabled_app"
Future<void> check(String repoUrl) async {
state = state.copyWith(status: UpdateStatus.checking);
try {
final packageInfo = await PackageInfo.fromPlatform();
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
final currentVersion =
'${packageInfo.version}.${packageInfo.buildNumber}';
// Parse repo URL → Forgejo API endpoint
final uri = Uri.parse(repoUrl);
final parts =
uri.pathSegments.where((s) => s.isNotEmpty).toList();
final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList();
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
final apiUrl =
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest';
@@ -70,20 +78,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
tagName.startsWith('v') ? tagName.substring(1) : tagName;
if (_isNewer(latestVersion, currentVersion)) {
final assets =
(response.data['assets'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final assets = (response.data['assets'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final apk = assets.firstWhere(
(a) => (a['name'] as String? ?? '').endsWith('.apk'),
orElse: () => {},
);
if (apk.isNotEmpty) {
final downloadUrl = apk['browser_download_url'] as String?;
state = state.copyWith(
status: UpdateStatus.available,
currentVersion: currentVersion,
latestVersion: latestVersion,
downloadUrl: apk['browser_download_url'] as String?,
downloadUrl: downloadUrl,
);
await _downloadInBackground();
return;
}
}
@@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
Future<void> downloadAndInstall() async {
Future<void> _downloadInBackground() async {
if (state.downloadUrl == null) return;
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try {
final dir = await _apkDir();
await _cleanupApks(dir);
final path = '${dir.path}/fabled_${state.latestVersion}.apk';
await Dio().download(
state.downloadUrl!,
path,
onReceiveProgress: (received, total) {
if (total > 0) {
state = state.copyWith(downloadProgress: received / total);
}
},
);
final file = File(path);
if (!await file.exists() || await file.length() == 0) {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage: 'Download failed — file is empty',
);
return;
}
state = state.copyWith(
status: UpdateStatus.readyToInstall,
apkPath: path,
downloadProgress: 1.0,
);
} catch (e) {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage: e.toString(),
);
}
}
Future<void> install() async {
final path = state.apkPath;
if (path == null) return;
// Android 8+ requires explicit per-app "Install unknown apps" approval
// beyond the manifest declaration. Check and redirect to Settings if needed.
final installPermission = await Permission.requestInstallPackages.status;
if (!installPermission.isGranted) {
final result = await Permission.requestInstallPackages.request();
@@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try {
final dir = await getExternalStorageDirectory() ??
await getTemporaryDirectory();
final path = '${dir.path}/fabled_update.apk';
await Dio().download(
state.downloadUrl!,
path,
onReceiveProgress: (received, total) {
if (total > 0) {
state = state.copyWith(downloadProgress: received / total);
}
},
);
final result = await OpenFile.open(
path,
type: 'application/vnd.android.package-archive',
);
if (result.type == ResultType.done) {
// Installer launched — reset to idle so the dialog closes naturally.
state = const UpdateState();
} else {
state = state.copyWith(
@@ -158,8 +190,30 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
/// Remove any previously cached APKs.
Future<void> cleanup() async {
final dir = await _apkDir();
await _cleanupApks(dir);
}
void dismiss() => state = const UpdateState();
Future<Directory> _apkDir() async {
return await getExternalStorageDirectory() ??
await getTemporaryDirectory();
}
Future<void> _cleanupApks(Directory dir) async {
try {
final entries = dir.listSync();
for (final entry in entries) {
if (entry is File && entry.path.endsWith('.apk')) {
await entry.delete();
}
}
} catch (_) {}
}
bool _isNewer(String latest, String current) {
try {
final l = latest.split('.').map(int.parse).toList();
+494
View File
@@ -0,0 +1,494 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:vad/vad.dart';
import 'api_client_provider.dart';
// ── Public helpers (also used by tests) ──────────────────────────────────────
class SentenceResult {
final List<String> sentences;
final String remainder;
const SentenceResult({required this.sentences, required this.remainder});
}
/// Extract completed sentences from [text] at `.`, `!`, `?` boundaries.
/// Returns the completed sentences and the unconsumed remainder.
SentenceResult extractSentences(String text) {
final boundary = RegExp(r'[.!?]+(?=\s|$)');
final sentences = <String>[];
var remaining = text;
RegExpMatch? match;
while ((match = boundary.firstMatch(remaining)) != null) {
final end = match!.end;
final sentence = remaining.substring(0, end).trim();
if (sentence.isNotEmpty) sentences.add(sentence);
remaining = remaining.substring(end).trimLeft();
}
return SentenceResult(sentences: sentences, remainder: remaining);
}
/// Strip markdown formatting before sending text to TTS.
String stripMarkdownForTts(String text) {
return text
.replaceAll(RegExp(r'```[\s\S]*?```'), '') // fenced code blocks
.replaceAllMapped(RegExp(r'`([^`]+)`'), (m) => m[1]!) // inline code
.replaceAll(RegExp(r'#{1,6}\s+'), '') // headings
.replaceAllMapped(RegExp(r'\*\*([^*]+)\*\*'), (m) => m[1]!) // bold
.replaceAllMapped(RegExp(r'\*([^*]+)\*'), (m) => m[1]!) // italic
.replaceAllMapped(
RegExp(r'\[([^\]]+)\]\([^)]+\)'), (m) => m[1]!) // links → text
.replaceAll(RegExp(r'^\s*[-*+]\s+', multiLine: true), '') // list markers
.replaceAll(RegExp(r'\n{2,}'), ' ') // multiple newlines → space
.replaceAll('\n', ' ')
.trim();
}
/// Encode float PCM samples (-1..1) as a 16-bit mono WAV at 16 kHz.
Uint8List encodeWav(List<double> samples, {int sampleRate = 16000}) {
final numSamples = samples.length;
final dataSize = numSamples * 2;
final fileSize = 44 + dataSize;
final buf = ByteData(fileSize);
// RIFF header
buf.setUint8(0, 0x52); // R
buf.setUint8(1, 0x49); // I
buf.setUint8(2, 0x46); // F
buf.setUint8(3, 0x46); // F
buf.setUint32(4, fileSize - 8, Endian.little);
buf.setUint8(8, 0x57); // W
buf.setUint8(9, 0x41); // A
buf.setUint8(10, 0x56); // V
buf.setUint8(11, 0x45); // E
// fmt chunk
buf.setUint8(12, 0x66); // f
buf.setUint8(13, 0x6D); // m
buf.setUint8(14, 0x74); // t
buf.setUint8(15, 0x20); // (space)
buf.setUint32(16, 16, Endian.little); // chunk size
buf.setUint16(20, 1, Endian.little); // PCM format
buf.setUint16(22, 1, Endian.little); // mono
buf.setUint32(24, sampleRate, Endian.little);
buf.setUint32(28, sampleRate * 2, Endian.little); // byte rate
buf.setUint16(32, 2, Endian.little); // block align
buf.setUint16(34, 16, Endian.little); // bits per sample
// data chunk
buf.setUint8(36, 0x64); // d
buf.setUint8(37, 0x61); // a
buf.setUint8(38, 0x74); // t
buf.setUint8(39, 0x61); // a
buf.setUint32(40, dataSize, Endian.little);
for (var i = 0; i < numSamples; i++) {
final clamped = samples[i].clamp(-1.0, 1.0);
final int16 = (clamped * 32767).round().clamp(-32768, 32767);
buf.setInt16(44 + i * 2, int16, Endian.little);
}
return buf.buffer.asUint8List();
}
// ── State ─────────────────────────────────────────────────────────────────────
enum VoiceMode { idle, recording, transcribing, playing }
class VoiceState {
final VoiceMode mode;
final bool voiceModeActive;
final bool available;
/// Normalized mic amplitude 0.01.0 while recording.
final double amplitude;
const VoiceState({
this.mode = VoiceMode.idle,
this.voiceModeActive = false,
this.available = true,
this.amplitude = 0.0,
});
VoiceState copyWith({
VoiceMode? mode,
bool? voiceModeActive,
bool? available,
double? amplitude,
}) =>
VoiceState(
mode: mode ?? this.mode,
voiceModeActive: voiceModeActive ?? this.voiceModeActive,
available: available ?? this.available,
amplitude: amplitude ?? this.amplitude,
);
}
// ── Provider ──────────────────────────────────────────────────────────────────
final voiceProvider =
NotifierProvider.autoDispose<VoiceNotifier, VoiceState>(VoiceNotifier.new);
// ── Notifier ──────────────────────────────────────────────────────────────────
class VoiceNotifier extends Notifier<VoiceState> {
// Audio playback
AudioPlayer? _player;
// VAD — sole owner of the microphone
VadHandler? _vadHandler;
StreamSubscription<void>? _vadSpeechStartSub;
StreamSubscription<List<double>>? _vadSpeechEndSub;
StreamSubscription<({double isSpeech, double notSpeech, List<double> frame})>?
_vadFrameSub;
StreamSubscription<String>? _vadErrorSub;
bool _speechDetected = false;
int _speechStartMs = 0;
static const _vadGraceMs = 1500;
bool _disposed = false;
// Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript;
void Function(String message)? _onError;
bool _enableTts = false;
// Streaming TTS state
String _sentenceBuffer = '';
int _lastSeenLength = 0;
bool _streamComplete = false;
// Whisper context hint
String _lastAssistantContent = '';
// Empty transcript counter
int _emptyTranscriptCount = 0;
// TTS playback queue
final _ttsQueue = Queue<Uint8List>();
bool _ttsPlaying = false;
int _ttsCounter = 0;
Directory? _tempDir;
@override
VoiceState build() {
_disposed = false;
_player = AudioPlayer();
ref.onDispose(() {
_disposed = true;
_cancelSubscriptions();
_vadHandler?.dispose();
_vadHandler = null;
_player?.dispose();
_player = null;
});
return const VoiceState();
}
// ── Public API ──────────────────────────────────────────────────────────────
Future<void> enterVoiceMode({
required Future<void> Function(String transcript) onTranscript,
bool enableTts = false,
required void Function(String message) onError,
}) async {
if (state.voiceModeActive) {
if (state.mode == VoiceMode.recording && !_speechDetected) {
onError('No speech detected');
}
exitVoiceMode();
return;
}
try {
final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.enabled || !status.stt) {
onError('Speech-to-text not available on this server');
return;
}
if (!status.tts) enableTts = false;
} catch (_) {
onError('Could not reach voice service');
return;
}
var permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.permanentlyDenied) {
onError('Microphone blocked — opening settings');
final opened = await openAppSettings();
if (!opened) return;
permStatus = await Permission.microphone.status;
}
if (!permStatus.isGranted) {
onError('Microphone permission required');
return;
}
_onTranscript = onTranscript;
_onError = onError;
_enableTts = enableTts;
_emptyTranscriptCount = 0;
_tempDir = await getTemporaryDirectory();
state = state.copyWith(voiceModeActive: true, available: true);
await _startListening();
}
void exitVoiceMode() {
_cleanup();
if (!_disposed) state = const VoiceState();
}
void feedContent(String fullContent, {required bool isComplete}) {
if (!state.voiceModeActive || !_enableTts) return;
final delta = fullContent.length > _lastSeenLength
? fullContent.substring(_lastSeenLength)
: '';
_lastSeenLength = fullContent.length;
_sentenceBuffer += delta;
_dispatchSentences(flush: isComplete);
if (isComplete) {
_lastAssistantContent = fullContent;
_streamComplete = true;
_checkRestartListening();
}
}
// ── Internal helpers ────────────────────────────────────────────────────────
void _cleanup() {
_cancelSubscriptions();
final handler = _vadHandler;
_vadHandler = null;
handler?.dispose();
_player?.stop();
_ttsQueue.clear();
_ttsPlaying = false;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
_speechDetected = false;
_onTranscript = null;
_onError = null;
}
void _cancelSubscriptions() {
_vadSpeechStartSub?.cancel();
_vadSpeechStartSub = null;
_vadSpeechEndSub?.cancel();
_vadSpeechEndSub = null;
_vadFrameSub?.cancel();
_vadFrameSub = null;
_vadErrorSub?.cancel();
_vadErrorSub = null;
}
// ── Internal recording ──────────────────────────────────────────────────────
Future<void> _startListening() async {
if (_disposed || !state.voiceModeActive) return;
_speechDetected = false;
_speechStartMs = 0;
try {
await _stopVad();
_vadHandler = VadHandler.create();
_vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
if (_disposed || !state.voiceModeActive) return;
if (!_speechDetected) {
_speechDetected = true;
_speechStartMs = DateTime.now().millisecondsSinceEpoch;
}
});
_vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((audioSamples) {
if (_disposed || !state.voiceModeActive) return;
final now = DateTime.now().millisecondsSinceEpoch;
final sinceStart = _speechStartMs > 0 ? now - _speechStartMs : 0;
if (_speechDetected && sinceStart >= _vadGraceMs) {
_stopVad();
_handleSpeechEnd(audioSamples);
}
});
_vadFrameSub = _vadHandler!.onFrameProcessed.listen((event) {
if (_disposed || !state.voiceModeActive) return;
final frame = event.frame;
if (frame.isEmpty) return;
double sumSq = 0;
for (final s in frame) {
sumSq += s * s;
}
final rms = sqrt(sumSq / frame.length);
final norm = (rms * 4.0).clamp(0.0, 1.0);
if ((norm - state.amplitude).abs() > 0.02) {
state = state.copyWith(amplitude: norm);
}
});
_vadErrorSub = _vadHandler!.onError.listen((msg) {
if (_disposed) return;
_onError?.call('VAD error: $msg');
});
await _vadHandler!.startListening(model: 'v5');
// Only show recording UI after the mic is actually open.
if (!_disposed && state.voiceModeActive) {
state = state.copyWith(mode: VoiceMode.recording, amplitude: 0.0);
}
} catch (e) {
_onError?.call('Microphone error: $e');
_cleanup();
if (!_disposed) state = const VoiceState();
}
}
Future<void> _stopVad() async {
_cancelSubscriptions();
if (_vadHandler != null) {
final handler = _vadHandler!;
_vadHandler = null;
await handler.dispose();
}
}
Future<void> _handleSpeechEnd(List<double> audioSamples) async {
if (_disposed || !state.voiceModeActive) return;
state = state.copyWith(mode: VoiceMode.transcribing);
try {
final wavBytes = encodeWav(audioSamples);
if (_disposed || !state.voiceModeActive) return;
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
wavBytes,
context:
_lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
);
if (_disposed || !state.voiceModeActive) return;
if (transcript.isEmpty) {
_emptyTranscriptCount++;
if (_emptyTranscriptCount >= 3) {
_onError?.call('No speech detected — tap the mic to try again');
_cleanup();
if (!_disposed) state = const VoiceState();
return;
}
await _startListening();
return;
}
_emptyTranscriptCount = 0;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
if (_enableTts && !_disposed) {
state = state.copyWith(mode: VoiceMode.playing);
}
await _onTranscript?.call(transcript);
// In STT-only mode (no TTS), return to idle after transcript is sent.
// The user taps the mic again to record another message.
if (!_enableTts && !_disposed && state.voiceModeActive) {
state = state.copyWith(mode: VoiceMode.idle);
}
} catch (e) {
_onError?.call('Voice error: transcription failed');
_cleanup();
if (!_disposed) state = const VoiceState();
}
}
// ── Internal TTS ────────────────────────────────────────────────────────────
void _dispatchSentences({required bool flush}) {
final result = extractSentences(_sentenceBuffer);
_sentenceBuffer = flush ? '' : result.remainder;
for (final sentence in result.sentences) {
_enqueueSentence(sentence);
}
if (flush && result.remainder.trim().length >= 3) {
_enqueueSentence(result.remainder.trim());
}
}
void _enqueueSentence(String sentence) {
final stripped = stripMarkdownForTts(sentence);
if (stripped.length < 3) return;
_synthesiseSentence(stripped);
}
Future<void> _synthesiseSentence(String text) async {
try {
final wavBytes =
await ref.read(voiceRepositoryProvider).synthesise(text);
if (_disposed || !state.voiceModeActive) return;
_ttsQueue.add(wavBytes);
if (!_ttsPlaying) _drainTtsQueue();
} catch (_) {
// Skip failed sentence — TTS errors are non-fatal
}
}
Future<void> _drainTtsQueue() async {
if (_ttsPlaying) return;
_ttsPlaying = true;
final dir = _tempDir ?? await getTemporaryDirectory();
try {
while (_ttsQueue.isNotEmpty && state.voiceModeActive) {
final wavBytes = _ttsQueue.removeFirst();
final path = '${dir.path}/tts_${_ttsCounter++}.wav';
final file = File(path);
await file.writeAsBytes(wavBytes);
try {
await _player!.setFilePath(path);
await _player!.play();
await _player!.processingStateStream.firstWhere(
(s) =>
s == ProcessingState.completed || s == ProcessingState.idle,
);
} finally {
await file.delete().catchError((_) => file);
}
}
} finally {
_ttsPlaying = false;
}
_checkRestartListening();
}
void _checkRestartListening() {
if (_disposed) return;
if (_streamComplete &&
_ttsQueue.isEmpty &&
!_ttsPlaying &&
state.voiceModeActive) {
_streamComplete = false;
_lastSeenLength = 0;
_sentenceBuffer = '';
_startListening();
}
}
}
+6 -5
View File
@@ -1,6 +1,7 @@
import 'dart:io' as io;
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -72,7 +73,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
_usernameController.text.trim(),
_passwordController.text,
);
if (mounted) context.go(Routes.briefing);
if (mounted) context.go(Routes.journal);
} on AuthException catch (e) {
setState(() => _error = e.message);
} on AppException catch (e) {
@@ -90,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
cookieJar: ref.read(cookieJarProvider),
onSuccess: () async {
await ref.read(authProvider.notifier).verify();
if (mounted) context.go(Routes.briefing);
if (mounted) context.go(Routes.journal);
},
),
));
@@ -119,7 +120,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
const SizedBox(height: 32),
if (_oauthEnabled) ...[
FilledButton.icon(
icon: const Icon(Icons.login),
icon: const Icon(LucideIcons.logIn),
label: const Text('Sign in with SSO'),
onPressed: _openOAuth,
),
@@ -155,8 +156,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscure
? Icons.visibility
: Icons.visibility_off),
? LucideIcons.eye
: LucideIcons.eyeOff),
onPressed: () =>
setState(() => _obscure = !_obscure),
),
@@ -1,88 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/briefing_conversation.dart';
import '../../data/models/message.dart';
import '../../providers/api_client_provider.dart';
import '../../widgets/chat_message_bubble.dart';
class BriefingHistoryScreen extends ConsumerWidget {
const BriefingHistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyAsync = ref.watch(_briefingHistoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('Past Briefings')),
body: historyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) =>
const Center(child: Text('Could not load briefing history.')),
data: (convs) {
if (convs.isEmpty) {
return const Center(child: Text('No past briefings.'));
}
return ListView.builder(
itemCount: convs.length,
itemBuilder: (context, i) {
final conv = convs[i];
final label = conv.briefingDate ?? conv.title;
return ListTile(
leading: const Icon(Icons.wb_sunny_outlined),
title: Text(label),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _BriefingDetailScreen(conv: conv),
),
),
);
},
);
},
),
);
}
}
/// Lazily loads and displays all messages for a past briefing.
class _BriefingDetailScreen extends ConsumerWidget {
final BriefingConversation conv;
const _BriefingDetailScreen({required this.conv});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesAsync = ref.watch(_briefingMessagesProvider(conv.id));
return Scaffold(
appBar: AppBar(title: Text(conv.briefingDate ?? conv.title)),
body: messagesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const Center(child: Text('Could not load messages.')),
data: (messages) {
if (messages.isEmpty) {
return const Center(child: Text('No messages.'));
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (_, i) => ChatMessageBubble(message: messages[i]),
);
},
),
);
}
}
// ── Private providers (scoped to this file) ──────────────────────────────────
final _briefingHistoryProvider =
FutureProvider<List<BriefingConversation>>((ref) async {
return ref.watch(briefingApiProvider).getHistory();
});
final _briefingMessagesProvider =
FutureProvider.family<List<Message>, int>((ref, convId) async {
return ref.watch(briefingApiProvider).getMessages(convId);
});
-461
View File
@@ -1,461 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/briefing_provider.dart';
import '../../providers/api_client_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/weather_card.dart';
import 'briefing_history_screen.dart';
class BriefingScreen extends ConsumerStatefulWidget {
const BriefingScreen({super.key});
@override
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
}
class _BriefingScreenState extends ConsumerState<BriefingScreen>
with WidgetsBindingObserver {
final _controller = TextEditingController();
final _scrollController = ScrollController();
bool _refreshing = false;
// rss_item_id -> 'up' | 'down' | null
final Map<int, String?> _reactions = {};
Timer? _pollTimer;
bool _appInForeground = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_appInForeground = state == AppLifecycleState.resumed;
}
void _pollSilently() {
if (!_appInForeground || !mounted) return;
final isStreaming = ref.read(isBriefingStreamingProvider);
if (isStreaming) return;
ref.read(briefingProvider.notifier).silentRefresh();
}
@override
void dispose() {
_pollTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
Future<void> _sendReply() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
try {
await ref.read(briefingProvider.notifier).sendReply(text);
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to send reply.')),
);
}
}
}
Future<void> _handleReaction(int itemId, String reaction) async {
final current = _reactions[itemId];
final next = current == reaction ? null : reaction;
setState(() => _reactions[itemId] = next);
final api = ref.read(briefingApiProvider);
try {
if (next == null) {
await api.deleteRssReaction(itemId);
} else {
await api.postRssReaction(itemId, reaction);
}
} catch (_) {
setState(() => _reactions[itemId] = current);
}
}
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
await ref.read(briefingProvider.notifier).refresh('compilation');
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not generate briefing.')),
);
}
} finally {
if (mounted) setState(() => _refreshing = false);
}
}
@override
Widget build(BuildContext context) {
final briefingAsync = ref.watch(briefingProvider);
final isStreaming = ref.watch(isBriefingStreamingProvider);
final scheme = Theme.of(context).colorScheme;
// Scroll to bottom when messages change
ref.listen(briefingProvider, (_, _) => _scrollToBottom());
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Briefing', style: Theme.of(context).textTheme.titleLarge),
Text(
_todayLabel(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
actions: [
if (_refreshing)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton(
icon: const Icon(Icons.refresh_outlined),
tooltip: 'Generate briefing',
onPressed: _refresh,
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'history') {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const BriefingHistoryScreen(),
));
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'history',
child: Text('View past briefings'),
),
],
),
],
),
body: briefingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Could not load today's briefing."),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(briefingProvider),
child: const Text('Retry'),
),
],
),
),
data: (conv) {
return Column(
children: [
Expanded(
child: CustomScrollView(
controller: _scrollController,
slivers: [
if (conv.messages.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'No briefing yet today.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('Generate now'),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
sliver: SliverList.builder(
itemCount: conv.messages.length,
itemBuilder: (_, i) {
final msg = conv.messages[i];
return _BriefingMessageItem(
message: msg,
reactions: _reactions,
onReaction: _handleReaction,
);
},
),
),
],
),
),
// Progress bar while streaming
if (isStreaming)
LinearProgressIndicator(
minHeight: 2,
color: scheme.primary,
),
// Reply bar
const Divider(height: 1),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Reply to your briefing…',
border: OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.newline,
enabled: !isStreaming,
),
),
const SizedBox(width: 8),
_GradientSendButton(
onPressed: isStreaming ? null : _sendReply,
isStreaming: isStreaming,
),
],
),
),
),
],
);
},
),
);
}
String _todayLabel() {
final now = DateTime.now();
const days = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'
];
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
}
}
/// Renders a single briefing message with optional WeatherCard above it
/// and RSS reaction buttons below it (for assistant messages with metadata).
class _BriefingMessageItem extends StatelessWidget {
final Message message;
final Map<int, String?> reactions;
final void Function(int itemId, String reaction) onReaction;
const _BriefingMessageItem({
required this.message,
required this.reactions,
required this.onReaction,
});
@override
Widget build(BuildContext context) {
final meta = message.metadata;
final isAssistant = message.role == MessageRole.assistant;
// Weather: show card above when metadata.weather key is present (even if null value)
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
// RSS reactions
final rssItemIds = isAssistant && meta != null
? (meta['rss_item_ids'] as List<dynamic>?)?.cast<int>() ?? []
: <int>[];
final scheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasWeatherKey) WeatherCard(weather: weatherData),
ChatMessageBubble(message: message),
if (rssItemIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rssItemIds.asMap().entries.map((entry) {
final index = entry.key;
final itemId = entry.value;
final current = reactions[itemId];
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Story ${index + 1}',
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
_ReactionButton(
emoji: '👍',
active: current == 'up',
onTap: () => onReaction(itemId, 'up'),
),
const SizedBox(width: 4),
_ReactionButton(
emoji: '👎',
active: current == 'down',
onTap: () => onReaction(itemId, 'down'),
),
],
),
);
}).toList(),
),
),
],
);
}
}
class _ReactionButton extends StatelessWidget {
final String emoji;
final bool active;
final VoidCallback onTap;
const _ReactionButton({
required this.emoji,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: active
? scheme.primary.withValues(alpha: 0.12)
: Colors.transparent,
border: Border.all(
color: active ? scheme.primary : scheme.outlineVariant,
),
borderRadius: BorderRadius.circular(6),
),
child: Text(emoji, style: const TextStyle(fontSize: 14)),
),
);
}
}
class _GradientSendButton extends StatelessWidget {
final VoidCallback? onPressed;
final bool isStreaming;
const _GradientSendButton({
required this.onPressed,
required this.isStreaming,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final disabled = onPressed == null;
return DecoratedBox(
decoration: BoxDecoration(
gradient: disabled
? null
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6366F1), Color(0xFF4F46E5)],
),
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
borderRadius: BorderRadius.circular(10),
),
child: IconButton(
icon: isStreaming
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(
Icons.send,
color: disabled
? scheme.onSurface.withValues(alpha: 0.38)
: Colors.white,
),
onPressed: onPressed,
),
);
}
}
+174
View File
@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../data/models/calendar_event.dart';
import '../../providers/calendar_provider.dart';
import 'event_form_sheet.dart';
class CalendarScreen extends ConsumerWidget {
const CalendarScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final calAsync = ref.watch(calendarProvider);
final notifier = ref.read(calendarProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Calendar')),
body: calAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Could not load calendar.'),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(calendarProvider),
child: const Text('Retry'),
),
],
),
),
data: (cal) => Column(
children: [
TableCalendar<CalendarEvent>(
firstDay: DateTime(2020),
lastDay: DateTime(2030),
focusedDay: cal.focusedMonth,
selectedDayPredicate: (day) => isSameDay(day, cal.selectedDay),
eventLoader: (day) =>
cal.eventsByDay[dateOnly(day)] ?? [],
calendarFormat: CalendarFormat.month,
headerStyle: const HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
),
calendarStyle: CalendarStyle(
selectedDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
todayDecoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
markerDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary,
shape: BoxShape.circle,
),
),
onDaySelected: (selected, _) => notifier.selectDay(selected),
onPageChanged: (focused) => notifier.loadMonth(focused),
),
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(calendarProvider.notifier).refresh(),
child: _AgendaList(
events:
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
notifier: notifier,
),
),
),
],
),
),
floatingActionButton: calAsync.hasValue
? FloatingActionButton(
onPressed: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => EventFormSheet(
event: null,
initialDate: calAsync.value!.selectedDay,
notifier: notifier,
),
),
child: const Icon(LucideIcons.plus),
)
: null,
);
}
}
// ── Agenda list ───────────────────────────────────────────────────────────────
class _AgendaList extends StatelessWidget {
final List<CalendarEvent> events;
final CalendarNotifier notifier;
const _AgendaList({required this.events, required this.notifier});
@override
Widget build(BuildContext context) {
if (events.isEmpty) {
return ListView(
children: const [
SizedBox(height: 80),
Center(child: Text('No events')),
],
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: events.length,
itemBuilder: (_, i) =>
_EventTile(event: events[i], notifier: notifier),
);
}
}
// ── Event tile ────────────────────────────────────────────────────────────────
class _EventTile extends StatelessWidget {
final CalendarEvent event;
final CalendarNotifier notifier;
const _EventTile({required this.event, required this.notifier});
Color _dotColor(BuildContext context) {
if (event.color.isEmpty) return Theme.of(context).colorScheme.primary;
try {
return Color(int.parse(event.color.replaceFirst('#', '0xFF')));
} catch (_) {
return Theme.of(context).colorScheme.primary;
}
}
String _timeLabel() {
if (event.allDay) return 'All day';
final h = event.startDt.hour.toString().padLeft(2, '0');
final m = event.startDt.minute.toString().padLeft(2, '0');
return '$h:$m';
}
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
radius: 6,
backgroundColor: _dotColor(context),
),
title: Text(event.title),
subtitle: Text(_timeLabel()),
onTap: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => EventFormSheet(
event: event,
initialDate: null,
notifier: notifier,
),
),
);
}
}
+477
View File
@@ -0,0 +1,477 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme.dart';
import '../../data/models/calendar_event.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/calendar_provider.dart';
class EventFormSheet extends ConsumerStatefulWidget {
/// null = create mode; non-null = edit mode.
final CalendarEvent? event;
/// Pre-fills start date in create mode. Ignored in edit mode.
final DateTime? initialDate;
final CalendarNotifier notifier;
const EventFormSheet({
super.key,
required this.event,
required this.initialDate,
required this.notifier,
});
@override
ConsumerState<EventFormSheet> createState() => _EventFormSheetState();
}
class _EventFormSheetState extends ConsumerState<EventFormSheet> {
late final TextEditingController _titleCtrl;
late final TextEditingController _descCtrl;
late final TextEditingController _locationCtrl;
late DateTime _startDt;
DateTime? _endDt;
late bool _allDay;
String? _recurrence; // null = None; one of the FREQ= strings otherwise
String _color = '';
bool _saving = false;
bool get _isCreate => widget.event == null;
/// Maps RRULE string (or null) to display label for the dropdown.
static const Map<String?, String> _knownRrules = {
null: 'None',
'FREQ=DAILY': 'Daily',
'FREQ=WEEKLY': 'Weekly',
'FREQ=MONTHLY': 'Monthly',
'FREQ=YEARLY': 'Yearly',
};
/// True when editing an event whose RRULE is not one of the 5 known patterns.
bool get _isCustomRrule =>
widget.event?.recurrence != null &&
!_knownRrules.containsKey(widget.event!.recurrence);
static const List<String> _presetColors = [
'#EF4444',
'#F59E0B',
'#10B981',
'#7C3AED',
'#8B5CF6',
'#EC4899',
];
@override
void initState() {
super.initState();
final e = widget.event;
_titleCtrl = TextEditingController(text: e?.title ?? '');
_descCtrl = TextEditingController(text: e?.description ?? '');
_locationCtrl = TextEditingController(text: e?.location ?? '');
if (e != null) {
_startDt = e.startDt.toLocal();
_endDt = e.endDt?.toLocal();
_allDay = e.allDay;
// Custom RRULEs are displayed as read-only; leave _recurrence = null.
_recurrence = _isCustomRrule ? null : e.recurrence;
_color = e.color;
} else {
final base = widget.initialDate ?? DateTime.now();
final now = DateTime.now();
final hour = now.minute >= 30 ? (now.hour + 1) % 24 : now.hour;
_startDt = DateTime(base.year, base.month, base.day, hour);
_allDay = false;
_recurrence = null;
}
}
@override
void dispose() {
_titleCtrl.dispose();
_descCtrl.dispose();
_locationCtrl.dispose();
super.dispose();
}
// ── Save ──────────────────────────────────────────────────────────────────
Future<void> _save() async {
if (_titleCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Title is required.')),
);
return;
}
setState(() => _saving = true);
try {
// Preserve unrecognised RRULEs unchanged on save.
final rrule = _isCustomRrule ? widget.event!.recurrence : _recurrence;
final payload = <String, dynamic>{
'title': _titleCtrl.text.trim(),
'start_dt': _startDt.toUtc().toIso8601String(),
if (_endDt != null) 'end_dt': _endDt!.toUtc().toIso8601String(),
'all_day': _allDay,
'description': _descCtrl.text.trim(),
'location': _locationCtrl.text.trim(),
'color': _color,
'recurrence': rrule,
};
final repo = ref.read(eventsRepositoryProvider);
if (_isCreate) {
final created = await repo.createEvent(payload);
widget.notifier.addEvent(created);
} else {
final updated = await repo.updateEvent(widget.event!.id, payload);
widget.notifier.updateEvent(updated);
}
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to save event.')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
// ── Delete ────────────────────────────────────────────────────────────────
Future<void> _delete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete this event?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
try {
await ref.read(eventsRepositoryProvider).deleteEvent(widget.event!.id);
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to delete event.')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
// ── Date/time pickers ─────────────────────────────────────────────────────
Future<void> _pickStartDate() async {
final d = await showDatePicker(
context: context,
initialDate: _startDt,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (d != null) {
setState(() {
_startDt = DateTime(
d.year, d.month, d.day, _startDt.hour, _startDt.minute);
});
}
}
Future<void> _pickStartTime() async {
final t = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_startDt),
);
if (t != null) {
setState(() {
_startDt = DateTime(
_startDt.year, _startDt.month, _startDt.day, t.hour, t.minute);
});
}
}
Future<void> _pickEndDate() async {
final d = await showDatePicker(
context: context,
initialDate: _endDt ?? _startDt,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (d != null) {
setState(() {
final prev = _endDt ?? _startDt;
_endDt =
DateTime(d.year, d.month, d.day, prev.hour, prev.minute);
});
}
}
Future<void> _pickEndTime() async {
final t = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_endDt ?? _startDt),
);
if (t != null) {
setState(() {
final prev = _endDt ?? _startDt;
_endDt = DateTime(
prev.year, prev.month, prev.day, t.hour, t.minute);
});
}
}
// ── Formatting helpers ────────────────────────────────────────────────────
String _fmtDate(DateTime dt) {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
return '${months[dt.month - 1]} ${dt.day}, ${dt.year}';
}
String _fmtTime(DateTime dt) =>
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
// ── Build ─────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Text(
_isCreate ? 'New Event' : 'Edit Event',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
if (!_isCreate)
IconButton(
icon: const Icon(LucideIcons.trash2),
color: Theme.of(context).colorScheme.error,
onPressed: _saving ? null : _delete,
),
],
),
const SizedBox(height: 16),
// Title
TextField(
controller: _titleCtrl,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 12),
// All-day toggle
SwitchListTile(
title: const Text('All day'),
value: _allDay,
onChanged: (v) => setState(() => _allDay = v),
contentPadding: EdgeInsets.zero,
),
// Start date
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.calendarDays),
title: Text(_fmtDate(_startDt)),
subtitle: const Text('Start date'),
onTap: _pickStartDate,
),
// Start time (hidden when all-day)
if (!_allDay)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.clock),
title: Text(_fmtTime(_startDt)),
subtitle: const Text('Start time'),
onTap: _pickStartTime,
),
// End date
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.calendarCheck),
title: Text(
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
subtitle: const Text('End date'),
onTap: _pickEndDate,
trailing: _endDt != null
? IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _endDt = null),
)
: null,
),
// End time (hidden when all-day or no end date)
if (!_allDay && _endDt != null)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.clock),
title: Text(_fmtTime(_endDt!)),
subtitle: const Text('End time'),
onTap: _pickEndTime,
),
const SizedBox(height: 8),
// Repeat — show read-only tile for custom RRULEs
if (_isCustomRrule)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.repeat),
title: const Text('Custom (read-only)'),
subtitle: Text(widget.event!.recurrence ?? ''),
)
else
Row(
children: [
const Icon(LucideIcons.repeat),
const SizedBox(width: 16),
DropdownButton<String?>(
value: _recurrence,
underline: const SizedBox.shrink(),
items: _knownRrules.entries
.map((entry) => DropdownMenuItem<String?>(
value: entry.key,
child: Text(entry.value),
))
.toList(),
onChanged: (v) => setState(() => _recurrence = v),
),
],
),
const SizedBox(height: 12),
// Description
TextField(
controller: _descCtrl,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder(),
),
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 12),
// Location
TextField(
controller: _locationCtrl,
decoration: const InputDecoration(
labelText: 'Location',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Color chips
Text('Color', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 8),
Row(
children: [
// "No color" chip
GestureDetector(
onTap: () => setState(() => _color = ''),
child: Container(
width: 32,
height: 32,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: _color.isEmpty
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: _color.isEmpty ? 2 : 1,
),
),
child: const Icon(LucideIcons.ban, size: 16),
),
),
..._presetColors.map((hex) {
final c =
Color(int.parse(hex.replaceFirst('#', '0xFF')));
return GestureDetector(
onTap: () => setState(() => _color = hex),
child: Container(
width: 32,
height: 32,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: _color == hex
? Border.all(
color: Theme.of(context)
.colorScheme
.primary,
width: 2,
)
: null,
),
),
);
}),
],
),
const SizedBox(height: 24),
// Save button — Moss action-primary per Hybrid rule
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).extension<ActionColors>()!.primary,
),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_isCreate ? 'Create' : 'Save'),
),
),
],
),
),
);
}
}
+165 -23
View File
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/chat_provider.dart';
import '../../providers/voice_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/voice_mic_button.dart';
class ChatScreen extends ConsumerStatefulWidget {
final int conversationId;
@@ -13,12 +17,56 @@ class ChatScreen extends ConsumerStatefulWidget {
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
class _ChatScreenState extends ConsumerState<ChatScreen>
with WidgetsBindingObserver {
final _controller = TextEditingController();
final _scrollController = ScrollController();
bool _refreshing = false;
Future<void> _refreshMessages() async {
if (_refreshing) return;
setState(() => _refreshing = true);
try {
await ref
.read(messagesProvider(widget.conversationId).notifier)
.refresh();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not refresh messages.')),
);
}
} finally {
if (mounted) setState(() => _refreshing = false);
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// If we land on a conversation whose last assistant message is already
// mid-stream (e.g. the /news discuss button creates a conv and
// auto-kicks generation), attach to the running stream so the user sees
// live tokens instead of a frozen placeholder.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref
.read(messagesProvider(widget.conversationId).notifier)
.attachToGeneration();
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(messagesProvider(widget.conversationId).notifier).refresh();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
super.dispose();
@@ -58,16 +106,54 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
}
Future<void> _toggleVoiceMode() async {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
ref.read(voiceProvider.notifier).exitVoiceMode();
return;
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
await ref
.read(messagesProvider(widget.conversationId).notifier)
.sendMessage(transcript);
},
enableTts: true,
onError: (msg) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
}
},
);
}
@override
Widget build(BuildContext context) {
final messagesAsync = ref.watch(messagesProvider(widget.conversationId));
final isStreaming = ref.watch(isStreamingProvider(widget.conversationId));
final streamingStatus =
ref.watch(streamingStatusProvider(widget.conversationId));
final voiceState = ref.watch(voiceProvider);
// Scroll when messages change
ref.listen(messagesProvider(widget.conversationId), (_, _) {
// Scroll when messages change.
ref.listen(messagesProvider(widget.conversationId), (prev, next) {
_scrollToBottom();
});
// Feed streaming content to VoiceNotifier for TTS.
ref.listen(messagesProvider(widget.conversationId), (prev, next) {
if (!voiceState.voiceModeActive) return;
final messages = next.value;
if (messages == null || messages.isEmpty) return;
final last = messages.last;
if (last.role != MessageRole.assistant) return;
final isComplete = last.status != 'generating';
ref
.read(voiceProvider.notifier)
.feedContent(last.content, isComplete: isComplete);
});
final convTitle = ref
.watch(conversationsProvider)
.value
@@ -78,6 +164,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
return Scaffold(
appBar: AppBar(
title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _refreshing ? null : _refreshMessages,
icon: _refreshing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(LucideIcons.refreshCw),
),
],
),
body: Column(
children: [
@@ -85,25 +184,55 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child: messagesAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, _) => const Center(
error: (err, stack) => const Center(
child: Text('Could not load messages.'),
),
data: (messages) {
if (messages.isEmpty) {
return const Center(
child: Text('Send a message to start.'));
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (context, i) =>
ChatMessageBubble(message: messages[i]),
return RefreshIndicator(
onRefresh: _refreshMessages,
child: messages.isEmpty
? ListView(
// Needs to be scrollable for RefreshIndicator to
// fire on empty state — plain Center won't work.
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 240),
Center(child: Text('Send a message to start.')),
],
)
: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (context, i) => ChatMessageBubble(
message: messages[i],
streamingStatus: (i == messages.length - 1 &&
messages[i].status == 'generating')
? streamingStatus
: '',
),
),
);
},
),
),
// Voice mode banner
if (voiceState.voiceModeActive)
Container(
width: double.infinity,
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: const Text(
'🎤 Listening… tap mic to exit voice mode',
style: TextStyle(
fontSize: 12,
color: Color(0xFFF87171),
),
),
),
const Divider(height: 1),
SafeArea(
child: Padding(
@@ -114,23 +243,37 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Message...',
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: voiceState.voiceModeActive
? 'Listening…'
: 'Message…',
hintStyle: voiceState.voiceModeActive
? const TextStyle(fontStyle: FontStyle.italic)
: null,
border: const OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
maxLines:
MediaQuery.of(context).size.width >= 600 ? 2 : 4,
minLines: 1,
textInputAction: TextInputAction.newline,
enabled: !isStreaming,
enabled: !isStreaming && !voiceState.voiceModeActive,
),
),
const SizedBox(width: 8),
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
IconButton.filled(
onPressed: isStreaming ? null : _send,
onPressed: (isStreaming || voiceState.voiceModeActive)
? null
: _send,
icon: isStreaming
? const SizedBox(
width: 20,
@@ -138,7 +281,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
: const Icon(LucideIcons.send),
),
],
),
@@ -149,4 +292,3 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
);
}
}
+111 -52
View File
@@ -1,33 +1,87 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/theme.dart';
import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
class ConversationsTabScreen extends ConsumerWidget {
class ConversationsTabScreen extends ConsumerStatefulWidget {
const ConversationsTabScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ConversationsTabScreen> createState() =>
_ConversationsTabScreenState();
}
class _ConversationsTabScreenState
extends ConsumerState<ConversationsTabScreen> {
int? _selectedConvId;
Future<void> _createConversation() async {
final conv =
await ref.read(conversationsProvider.notifier).create('');
if (!mounted) return;
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
setState(() => _selectedConvId = conv.id);
} else {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
}
void _openConversation(int id) {
final isWide = MediaQuery.of(context).size.width >= 600;
if (isWide) {
setState(() => _selectedConvId = id);
} else {
context.push(Routes.chat.replaceFirst(':id', '$id'));
}
}
Future<void> _confirmDelete(int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete')),
],
),
);
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
if (_selectedConvId == id) {
setState(() => _selectedConvId = null);
}
}
}
@override
Widget build(BuildContext context) {
final isWide = MediaQuery.of(context).size.width >= 600;
final theme = Theme.of(context);
final convsAsync = ref.watch(conversationsProvider);
return Scaffold(
final listPanel = Scaffold(
appBar: AppBar(
title: Text('Chat', style: theme.textTheme.titleLarge),
actions: [
IconButton(
icon: const Icon(Icons.add),
icon: const Icon(LucideIcons.plus),
tooltip: 'New conversation',
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
onPressed: _createConversation,
),
],
),
@@ -40,50 +94,45 @@ class ConversationsTabScreen extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline,
Icon(LucideIcons.messageCircle,
size: 48, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('No conversations yet',
style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
FilledButton.icon(
icon: const Icon(Icons.add),
icon: const Icon(LucideIcons.plus),
label: const Text('Start a conversation'),
onPressed: () async {
final conv = await ref
.read(conversationsProvider.notifier)
.create('New conversation');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
onPressed: _createConversation,
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(conversationsProvider),
onRefresh: () =>
ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder(
itemCount: convs.length,
itemBuilder: (ctx, i) {
final c = convs[i];
final selected = isWide && c.id == _selectedConvId;
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
title: Text(c.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
leading: const Icon(LucideIcons.messageCircle),
title: Text(
c.title.isEmpty ? 'New conversation' : c.title,
maxLines: 1,
overflow: TextOverflow.ellipsis),
subtitle: Text(
_relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall,
),
selected: selected,
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
_confirmDelete(context, ref, c.id, c.title),
icon: const Icon(LucideIcons.trash2),
onPressed: () => _confirmDelete(c.id, c.title),
),
onTap: () =>
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
onTap: () => _openConversation(c.id),
);
},
),
@@ -91,28 +140,38 @@ class ConversationsTabScreen extends ConsumerWidget {
},
),
);
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete')),
],
),
if (!isWide) return listPanel;
return Row(
children: [
SizedBox(
width: 320,
child: listPanel,
),
const VerticalDivider(width: 1),
Expanded(
child: _selectedConvId != null
? ChatScreen(
key: ValueKey(_selectedConvId),
conversationId: _selectedConvId!,
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.messageCircle,
size: 48,
color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('Select a conversation',
style: theme.textTheme.titleMedium),
],
),
),
),
],
);
if (ok == true) {
await ref.read(conversationsProvider.notifier).delete(id);
}
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/journal_day.dart';
import '../../providers/api_client_provider.dart';
import '../../widgets/chat_message_bubble.dart';
class JournalHistoryScreen extends ConsumerWidget {
const JournalHistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final daysAsync = ref.watch(_journalDaysProvider);
return Scaffold(
appBar: AppBar(title: const Text('Past Days')),
body: daysAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) =>
const Center(child: Text('Could not load journal history.')),
data: (days) {
if (days.isEmpty) {
return const Center(child: Text('No past days.'));
}
return ListView.builder(
itemCount: days.length,
itemBuilder: (context, i) {
final isoDate = days[i];
return ListTile(
leading: const Icon(LucideIcons.bookOpen),
title: Text(isoDate),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _JournalDayDetailScreen(isoDate: isoDate),
),
),
);
},
);
},
),
);
}
}
class _JournalDayDetailScreen extends ConsumerWidget {
final String isoDate;
const _JournalDayDetailScreen({required this.isoDate});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dayAsync = ref.watch(_journalDayProvider(isoDate));
return Scaffold(
appBar: AppBar(title: Text(isoDate)),
body: dayAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) =>
const Center(child: Text('Could not load that day.')),
data: (day) {
if (day.messages.isEmpty) {
return const Center(child: Text('No messages.'));
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
itemCount: day.messages.length,
itemBuilder: (_, i) => ChatMessageBubble(message: day.messages[i]),
);
},
),
);
}
}
// ── Private providers (scoped to this file) ──────────────────────────────────
final _journalDaysProvider = FutureProvider<List<String>>((ref) async {
return ref.watch(journalApiProvider).getDays();
});
final _journalDayProvider =
FutureProvider.family<JournalDay, String>((ref, isoDate) async {
return ref.watch(journalApiProvider).getDay(isoDate);
});
+429
View File
@@ -0,0 +1,429 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/journal_provider.dart';
import '../../providers/voice_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/voice_mic_button.dart';
import 'journal_history_screen.dart';
class JournalScreen extends ConsumerStatefulWidget {
const JournalScreen({super.key});
@override
ConsumerState<JournalScreen> createState() => _JournalScreenState();
}
class _JournalScreenState extends ConsumerState<JournalScreen>
with WidgetsBindingObserver {
final _controller = TextEditingController();
final _scrollController = ScrollController();
bool _refreshing = false;
Timer? _pollTimer;
bool _appInForeground = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_pollTimer =
Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasBackground = !_appInForeground;
_appInForeground = state == AppLifecycleState.resumed;
if (_appInForeground && wasBackground && mounted) {
ref.read(journalProvider.notifier).refreshMessages();
}
}
void _pollSilently() {
if (!_appInForeground || !mounted) return;
final isStreaming = ref.read(isJournalStreamingProvider);
if (isStreaming) return;
ref.read(journalProvider.notifier).silentRefresh();
}
Future<void> _pullToRefresh() async {
try {
await ref.read(journalProvider.notifier).refreshMessages();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not refresh.')),
);
}
}
}
@override
void dispose() {
_pollTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
Future<void> _sendReply() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
try {
await ref.read(journalProvider.notifier).sendReply(text);
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to send reply.')),
);
}
}
}
Future<void> _toggleVoiceMode() async {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
ref.read(voiceProvider.notifier).exitVoiceMode();
return;
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
await ref.read(journalProvider.notifier).sendReply(transcript);
},
enableTts: true,
onError: (msg) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(msg)));
}
},
);
}
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
await ref.read(journalProvider.notifier).regeneratePrep();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not regenerate prep.')),
);
}
} finally {
if (mounted) setState(() => _refreshing = false);
}
}
@override
Widget build(BuildContext context) {
final journalAsync = ref.watch(journalProvider);
final isStreaming = ref.watch(isJournalStreamingProvider);
final voiceState = ref.watch(voiceProvider);
final scheme = Theme.of(context).colorScheme;
ref.listen(journalProvider, (prev, next) => _scrollToBottom());
// Feed streaming assistant content to VoiceNotifier for TTS.
ref.listen(journalProvider, (prev, next) {
if (!voiceState.voiceModeActive) return;
final day = next.value;
if (day == null || day.messages.isEmpty) return;
final last = day.messages.last;
if (last.role != MessageRole.assistant) return;
final isComplete = last.status != 'generating';
ref
.read(voiceProvider.notifier)
.feedContent(last.content, isComplete: isComplete);
});
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Journal', style: Theme.of(context).textTheme.titleLarge),
Text(
_todayLabel(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
actions: [
if (_refreshing)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton(
icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Regenerate prep',
onPressed: _refresh,
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'history') {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const JournalHistoryScreen(),
));
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'history',
child: Text('Past days'),
),
],
),
],
),
body: journalAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Could not load today's journal."),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(journalProvider),
child: const Text('Retry'),
),
],
),
),
data: (day) {
final isWide = MediaQuery.of(context).size.width >= 600;
Widget body = Column(
children: [
Expanded(
child: RefreshIndicator(
onRefresh: _pullToRefresh,
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (day.messages.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'No prep yet today.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('Generate now'),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
sliver: SliverList.builder(
itemCount: day.messages.length,
itemBuilder: (_, i) =>
_JournalMessageItem(message: day.messages[i]),
),
),
],
),
),
),
if (isStreaming)
LinearProgressIndicator(
minHeight: 2,
color: scheme.primary,
),
if (voiceState.voiceModeActive)
Container(
width: double.infinity,
color: const Color(0xFFEF4444).withValues(alpha: 0.12),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
child: const Text(
'🎤 Listening… tap mic to exit voice mode',
style: TextStyle(
fontSize: 12,
color: Color(0xFFF87171),
),
),
),
const Divider(height: 1),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: voiceState.voiceModeActive
? 'Listening…'
: 'Tell your journal…',
hintStyle: voiceState.voiceModeActive
? const TextStyle(fontStyle: FontStyle.italic)
: null,
border: const OutlineInputBorder(),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
),
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.newline,
enabled: !isStreaming && !voiceState.voiceModeActive,
),
),
const SizedBox(width: 8),
VoiceMicButton(
mode: voiceState.mode,
voiceModeActive: voiceState.voiceModeActive,
amplitude: voiceState.amplitude,
onTap: _toggleVoiceMode,
),
const SizedBox(width: 6),
_GradientSendButton(
onPressed: (isStreaming || voiceState.voiceModeActive)
? null
: _sendReply,
isStreaming: isStreaming,
),
],
),
),
),
],
);
if (isWide) {
body = Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: body,
),
);
}
return body;
},
),
);
}
String _todayLabel() {
final now = DateTime.now();
const days = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday'
];
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
return '${days[now.weekday - 1]}, ${months[now.month - 1]} ${now.day}';
}
}
/// Renders a single journal message. The daily-prep prose itself already
/// covers weather ("Weather at home will reach a high of 15.9° ..."), so
/// the journal screen leaves rendering to the message bubble — no separate
/// weather card on top.
class _JournalMessageItem extends StatelessWidget {
final Message message;
const _JournalMessageItem({required this.message});
@override
Widget build(BuildContext context) {
return ChatMessageBubble(message: message);
}
}
class _GradientSendButton extends StatelessWidget {
final VoidCallback? onPressed;
final bool isStreaming;
const _GradientSendButton({
required this.onPressed,
required this.isStreaming,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final disabled = onPressed == null;
return DecoratedBox(
decoration: BoxDecoration(
gradient: disabled
? null
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF5B4A8A), Color(0xFF3F3560)],
),
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
borderRadius: BorderRadius.circular(10),
),
child: IconButton(
icon: isStreaming
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Icon(
LucideIcons.send,
color: disabled
? scheme.onSurface.withValues(alpha: 0.38)
: Colors.white,
),
onPressed: onPressed,
),
);
}
}
+327
View File
@@ -0,0 +1,327 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/knowledge_provider.dart';
import '../../widgets/knowledge_item_card.dart';
// Type tab configuration: (label, noteType filter value)
const _kTabs = [
(label: 'All', type: null),
(label: 'Notes', type: 'note'),
(label: 'People', type: 'person'),
(label: 'Places', type: 'place'),
(label: 'Lists', type: 'list'),
(label: 'Tasks', type: 'task'),
];
class KnowledgeScreen extends ConsumerStatefulWidget {
const KnowledgeScreen({super.key});
@override
ConsumerState<KnowledgeScreen> createState() => _KnowledgeScreenState();
}
class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
late final ScrollController _scrollController;
bool _searchActive = false;
final _searchController = TextEditingController();
var _debounce = DateTime.now();
@override
void initState() {
super.initState();
_tabController = TabController(length: _kTabs.length, vsync: this);
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
// Trigger initial load
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(knowledgeProvider.notifier).refresh();
});
_tabController.addListener(_onTabChanged);
}
@override
void dispose() {
_tabController.dispose();
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
void _onTabChanged() {
if (!_tabController.indexIsChanging) return;
final newType = _kTabs[_tabController.index].type;
ref.read(knowledgeProvider.notifier).setTypeFilter(newType);
}
void _onScroll() {
final pos = _scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 300) {
ref.read(knowledgeProvider.notifier).hydrateNext();
}
}
void _onSearchChanged(String q) {
final now = DateTime.now();
_debounce = now;
Future.delayed(const Duration(milliseconds: 400), () {
if (_debounce == now && mounted) {
ref.read(knowledgeProvider.notifier).setSearch(q);
}
});
}
String _tabLabel(int index) => _kTabs[index].label;
@override
Widget build(BuildContext context) {
final state = ref.watch(knowledgeProvider);
return Scaffold(
appBar: AppBar(
title: _searchActive
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search knowledge…',
border: InputBorder.none,
),
onChanged: _onSearchChanged,
)
: const Text('Knowledge'),
actions: [
IconButton(
icon: Icon(_searchActive ? LucideIcons.x : LucideIcons.search),
onPressed: () {
setState(() => _searchActive = !_searchActive);
if (!_searchActive) {
_searchController.clear();
ref.read(knowledgeProvider.notifier).setSearch(null);
}
},
),
],
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: List.generate(
_kTabs.length,
(i) => Tab(text: _tabLabel(i)),
),
),
),
body: Column(
children: [
// Tag filter chips
if (state.availableTags.isNotEmpty)
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: state.availableTags.length,
separatorBuilder: (_, _) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final tag = state.availableTags[i];
final active = state.activeTags.contains(tag);
return FilterChip(
label: Text(tag),
selected: active,
onSelected: (_) =>
ref.read(knowledgeProvider.notifier).toggleTag(tag),
visualDensity: VisualDensity.compact,
);
},
),
),
// Main list
Expanded(
child: _buildList(state),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _onFabTapped(context),
tooltip: 'New',
child: const Icon(LucideIcons.pencil),
),
);
}
Widget _buildList(KnowledgeState state) {
if (state.isLoadingIds && state.ids.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null && state.ids.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Failed to load',
style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 8),
TextButton(
onPressed: () =>
ref.read(knowledgeProvider.notifier).refresh(),
child: const Text('Retry'),
),
],
),
);
}
final items = state.orderedItems;
if (items.isEmpty && !state.isLoadingIds && !state.isLoadingBatch) {
return Center(
child: Text(
'No ${_kTabs[_tabController.index].label.toLowerCase()} yet.',
style: Theme.of(context).textTheme.bodyLarge,
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth >= 900
? 3
: constraints.maxWidth >= 600
? 2
: 1;
if (cols == 1) {
return ListView.separated(
controller: _scrollController,
itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) {
if (i >= items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return KnowledgeItemCard(item: items[i]);
},
);
}
return CustomScrollView(
controller: _scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.8,
),
delegate: SliverChildBuilderDelegate(
(_, i) {
if (i >= items.length) {
return const Center(
child: CircularProgressIndicator());
}
return KnowledgeItemCard(item: items[i])
.buildGridCard(context);
},
childCount:
items.length + (state.isLoadingBatch ? 1 : 0),
),
),
),
],
);
},
),
);
}
void _onFabTapped(BuildContext context) {
final currentType = _kTabs[_tabController.index].type;
if (currentType == 'task') {
context.push('/tasks/new');
return;
}
_showTypePicker(context);
}
void _showTypePicker(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_TypePickerRow(
icon: LucideIcons.fileText,
label: 'Note',
description: 'General note or document',
onTap: () {
Navigator.pop(sheetContext);
context.push('/notes/new', extra: {'noteType': 'note'});
},
),
_TypePickerRow(
icon: LucideIcons.user,
label: 'Person',
description: 'Contact, colleague, or reference person',
onTap: () {
Navigator.pop(sheetContext);
context.push('/notes/new', extra: {'noteType': 'person'});
},
),
_TypePickerRow(
icon: LucideIcons.mapPin,
label: 'Place',
description: 'Location, venue, or place of interest',
onTap: () {
Navigator.pop(sheetContext);
context.push('/notes/new', extra: {'noteType': 'place'});
},
),
_TypePickerRow(
icon: LucideIcons.listChecks,
label: 'List',
description: 'Checklist or structured list',
onTap: () {
Navigator.pop(sheetContext);
context.push('/notes/new', extra: {'noteType': 'list'});
},
),
],
),
),
);
}
}
class _TypePickerRow extends StatelessWidget {
final IconData icon;
final String label;
final String description;
final VoidCallback onTap;
const _TypePickerRow({
required this.icon,
required this.label,
required this.description,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Text(description),
onTap: onTap,
);
}
}
-294
View File
@@ -1,294 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/models/note.dart';
import '../../data/models/task.dart';
import '../../providers/notes_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../widgets/library_item_card.dart';
enum _LibraryFilter { all, notes, tasks, projects }
enum _TaskStatusFilter { all, todo, inProgress, done }
class LibraryScreen extends ConsumerStatefulWidget {
const LibraryScreen({super.key});
@override
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends ConsumerState<LibraryScreen> {
_LibraryFilter _filter = _LibraryFilter.all;
_TaskStatusFilter _taskStatus = _TaskStatusFilter.all;
bool _searchActive = false;
String _searchQuery = '';
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
bool _matchesSearch(String text) {
if (_searchQuery.isEmpty) return true;
return text.toLowerCase().contains(_searchQuery.toLowerCase());
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final notesAsync = ref.watch(notesProvider);
final tasksAsync = ref.watch(tasksProvider);
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
appBar: AppBar(
title: _searchActive
? TextField(
controller: _searchController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search…',
border: InputBorder.none,
isDense: true,
),
onChanged: (q) => setState(() => _searchQuery = q),
)
: Text('Library', style: theme.textTheme.titleLarge),
actions: [
IconButton(
icon: Icon(_searchActive ? Icons.close : Icons.search),
onPressed: () => setState(() {
_searchActive = !_searchActive;
if (!_searchActive) {
_searchQuery = '';
_searchController.clear();
}
}),
),
],
),
body: Column(
children: [
// ── Filter pills ──────────────────────────────────────────────────
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row(
children: _LibraryFilter.values.map((f) {
final label = switch (f) {
_LibraryFilter.all => 'All',
_LibraryFilter.notes => 'Notes',
_LibraryFilter.tasks => 'Tasks',
_LibraryFilter.projects => 'Projects',
};
final selected = _filter == f;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(label),
selected: selected,
onSelected: (_) => setState(() {
_filter = f;
_taskStatus = _TaskStatusFilter.all;
}),
selectedColor:
theme.colorScheme.primary.withValues(alpha: 0.18),
checkmarkColor: theme.colorScheme.primary,
),
);
}).toList(),
),
),
// ── Task status sub-filter (Tasks pill only) ───────────────────
if (_filter == _LibraryFilter.tasks)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Row(
children: _TaskStatusFilter.values.map((s) {
final label = switch (s) {
_TaskStatusFilter.all => 'All',
_TaskStatusFilter.todo => 'To Do',
_TaskStatusFilter.inProgress => 'In Progress',
_TaskStatusFilter.done => 'Done',
};
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: _taskStatus == s,
onSelected: (_) => setState(() => _taskStatus = s),
selectedColor:
theme.colorScheme.secondary.withValues(alpha: 0.15),
),
);
}).toList(),
),
),
const Divider(height: 1),
// ── Content ───────────────────────────────────────────────────────
Expanded(
child: switch (_filter) {
_LibraryFilter.notes => _buildNotesList(notesAsync),
_LibraryFilter.tasks => _buildTasksList(tasksAsync),
_LibraryFilter.projects => _buildProjectsList(projectsAsync),
_LibraryFilter.all => _buildAllList(notesAsync, tasksAsync),
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showCreateSheet(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildNotesList(AsyncValue<List<Note>> notesAsync) {
return notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (notes) {
final filtered = notes
.where((n) => _matchesSearch(n.title) || _matchesSearch(n.body))
.toList();
if (filtered.isEmpty) {
return const Center(child: Text('No notes found'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(notesProvider),
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) => NoteLibraryCard(note: filtered[i]),
),
);
},
);
}
Widget _buildTasksList(AsyncValue<List<Task>> tasksAsync) {
return tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (tasks) {
var filtered = tasks.where((t) => _matchesSearch(t.title)).toList();
if (_taskStatus != _TaskStatusFilter.all) {
final status = switch (_taskStatus) {
_TaskStatusFilter.todo => TaskStatus.todo,
_TaskStatusFilter.inProgress => TaskStatus.inProgress,
_TaskStatusFilter.done => TaskStatus.done,
_ => TaskStatus.todo,
};
filtered = filtered.where((t) => t.status == status).toList();
}
if (filtered.isEmpty) {
return const Center(child: Text('No tasks found'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(tasksProvider),
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, i) => TaskLibraryCard(task: filtered[i]),
),
);
},
);
}
Widget _buildProjectsList(AsyncValue<List<dynamic>> projectsAsync) {
return projectsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (projects) {
if (projects.isEmpty) {
return const Center(child: Text('No projects'));
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(projectsProvider),
child: ListView.builder(
itemCount: projects.length,
itemBuilder: (_, i) =>
ProjectLibraryCard(project: projects[i]),
),
);
},
);
}
Widget _buildAllList(
AsyncValue<List<Note>> notesAsync,
AsyncValue<List<Task>> tasksAsync,
) {
final notes = notesAsync.value ?? [];
final tasks = tasksAsync.value ?? [];
// Merge and sort by updatedAt desc
final items = <(DateTime, Widget)>[];
for (final n in notes) {
if (_matchesSearch(n.title) || _matchesSearch(n.body)) {
items.add((n.updatedAt, NoteLibraryCard(note: n)));
}
}
for (final t in tasks) {
if (_matchesSearch(t.title)) {
items.add((t.updatedAt, TaskLibraryCard(task: t)));
}
}
items.sort((a, b) => b.$1.compareTo(a.$1));
if (notesAsync.isLoading || tasksAsync.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (items.isEmpty) {
return const Center(child: Text('Nothing here yet'));
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(notesProvider);
ref.invalidate(tasksProvider);
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => items[i].$2,
),
);
}
void _showCreateSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.article_outlined),
title: const Text('New note'),
onTap: () {
Navigator.pop(context);
context.push(Routes.noteNew);
},
),
ListTile(
leading: const Icon(Icons.check_box_outlined),
title: const Text('New task'),
onTap: () {
Navigator.pop(context);
context.push(Routes.taskNew);
},
),
],
),
),
);
}
}
+54 -22
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/local/database.dart';
import '../../data/models/milestone.dart';
import '../../data/models/project.dart';
import '../../data/models/task.dart';
@@ -10,6 +12,7 @@ import '../../providers/api_client_provider.dart';
import '../../providers/milestones_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../widgets/pending_sync_badge.dart';
class ProjectTasksScreen extends ConsumerStatefulWidget {
final int projectId;
@@ -48,12 +51,18 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
}
}
void _openTask(int taskId) {
context
.push(Routes.taskEdit.replaceFirst(':id', '$taskId'))
.then((_) => ref.invalidate(projectTasksProvider(widget.projectId)));
}
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
if (hex == null || hex.isEmpty) return const Color(0xFF5B4A8A);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
return const Color(0xFF5B4A8A);
}
}
@@ -88,6 +97,14 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
),
],
),
actions: [
IconButton(
icon: const Icon(LucideIcons.pencil),
tooltip: 'Edit project',
onPressed: () =>
context.push('/projects/${widget.projectId}/edit'),
),
],
),
body: tasksAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -119,7 +136,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_box_outlined,
Icon(LucideIcons.checkSquare,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
@@ -137,8 +154,10 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
return RefreshIndicator(
onRefresh: () async {
setState(() => _pendingStatus.clear());
ref.invalidate(projectTasksProvider(widget.projectId));
ref.invalidate(projectMilestonesProvider(widget.projectId));
await Future.wait([
ref.refresh(projectTasksProvider(widget.projectId).future),
ref.refresh(projectMilestonesProvider(widget.projectId).future),
]);
},
child: CustomScrollView(
slivers: [
@@ -163,6 +182,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
},
),
@@ -190,6 +210,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
task: task,
effectiveStatus: _effectiveStatus(task),
onStatusTap: () => _cycleStatus(task),
onTap: () => _openTask(task.id),
);
},
),
@@ -274,17 +295,19 @@ class _TaskRow extends StatelessWidget {
final Task task;
final TaskStatus effectiveStatus;
final VoidCallback onStatusTap;
final VoidCallback onTap;
const _TaskRow({
required this.task,
required this.effectiveStatus,
required this.onStatusTap,
required this.onTap,
});
IconData get _statusIcon => switch (effectiveStatus) {
TaskStatus.done => Icons.check_circle,
TaskStatus.inProgress => Icons.timelapse,
TaskStatus.todo => Icons.radio_button_unchecked,
TaskStatus.done => LucideIcons.checkCircle2,
TaskStatus.inProgress => LucideIcons.loader,
TaskStatus.todo => LucideIcons.circle,
};
Color _statusColor(BuildContext context) {
@@ -309,8 +332,7 @@ class _TaskRow extends StatelessWidget {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
@@ -326,18 +348,28 @@ class _TaskRow extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: effectiveStatus == TaskStatus.done
? TextDecoration.lineThrough
: null,
color: effectiveStatus == TaskStatus.done
? theme.colorScheme.onSurfaceVariant
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
Row(
children: [
Flexible(
child: Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: effectiveStatus == TaskStatus.done
? TextDecoration.lineThrough
: null,
color: effectiveStatus == TaskStatus.done
? theme.colorScheme.onSurfaceVariant
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
PendingSyncBadge(
domain: kSyncDomainTasks,
id: task.id,
),
],
),
if (task.dueDate != null) ...[
const SizedBox(height: 2),
+9 -2
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/theme.dart';
import '../../core/wikilink_syntax.dart';
import '../../providers/notes_provider.dart';
@@ -44,13 +46,13 @@ class NoteDetailScreen extends ConsumerWidget {
data: (note) => Row(
children: [
IconButton(
icon: const Icon(Icons.edit),
icon: const Icon(LucideIcons.pencil),
onPressed: () => context.push(
Routes.noteEdit.replaceFirst(':id', '$noteId'),
),
),
IconButton(
icon: const Icon(Icons.delete),
icon: const Icon(LucideIcons.trash2),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
@@ -65,6 +67,11 @@ class NoteDetailScreen extends ConsumerWidget {
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext)
.extension<ActionColors>()!
.destructive,
),
child: const Text('Delete'),
),
],
+32 -6
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart';
import '../../core/theme.dart';
import '../../core/wikilink_syntax.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/notes_provider.dart';
@@ -11,7 +13,8 @@ import '../../widgets/project_selector.dart';
class NoteEditScreen extends ConsumerStatefulWidget {
final int? noteId;
const NoteEditScreen({super.key, this.noteId});
final String? noteType; // passed when creating a typed note from KnowledgeScreen
const NoteEditScreen({super.key, this.noteId, this.noteType});
@override
ConsumerState<NoteEditScreen> createState() => _NoteEditScreenState();
@@ -25,12 +28,14 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
int? _projectId;
bool _preview = false;
bool _saving = false;
late String _noteType;
late final Future<void> _initFuture;
@override
void initState() {
super.initState();
_noteType = widget.noteType ?? 'note';
_initFuture =
widget.noteId != null ? _loadExisting() : Future.value();
}
@@ -50,6 +55,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
_contentController.text = note.body;
_tags = List<String>.from(note.tags);
_projectId = note.projectId;
_noteType = note.noteType;
}
void _addTag(String raw) {
@@ -81,6 +87,9 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete'),
),
],
@@ -108,6 +117,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
body,
tags: _tags,
projectId: _projectId,
noteType: _noteType,
);
if (mounted) context.pop();
} else {
@@ -118,6 +128,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
tags: _tags,
projectId: _projectId,
clearProject: _projectId == null,
noteType: _noteType,
);
if (mounted) context.pop();
}
@@ -138,16 +149,31 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
builder: (context, snapshot) {
return Scaffold(
appBar: AppBar(
title: Text(widget.noteId == null ? 'New Note' : 'Edit Note'),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.noteId == null ? 'New Note' : 'Edit Note'),
if (_noteType != 'note')
Chip(
label: Text(
_noteType,
style: const TextStyle(fontSize: 11),
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
actions: [
if (widget.noteId != null)
IconButton(
icon: const Icon(Icons.delete_outline),
icon: const Icon(LucideIcons.trash2),
tooltip: 'Delete',
onPressed: _delete,
),
IconButton(
icon: Icon(_preview ? Icons.edit : Icons.preview),
icon: Icon(_preview ? LucideIcons.pencil : LucideIcons.eye),
tooltip: _preview ? 'Edit' : 'Preview',
onPressed: () => setState(() => _preview = !_preview),
),
@@ -158,7 +184,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
: const Icon(LucideIcons.check),
onPressed: _saving ? null : _save,
),
],
@@ -251,7 +277,7 @@ class _TagInput extends StatelessWidget {
label: Text('#$tag', style: const TextStyle(fontSize: 12)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 4),
deleteIcon: const Icon(Icons.close, size: 14),
deleteIcon: const Icon(LucideIcons.x, size: 14),
onDeleted: () => onRemove(tag),
),
),
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart';
import '../../providers/projects_provider.dart';
class ProjectEditScreen extends ConsumerStatefulWidget {
final int? projectId;
const ProjectEditScreen({super.key, this.projectId});
@override
ConsumerState<ProjectEditScreen> createState() => _ProjectEditScreenState();
}
class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
final _titleController = TextEditingController();
final _descController = TextEditingController();
final _goalController = TextEditingController();
String _status = 'active';
bool _saving = false;
late final Future<void> _initFuture;
@override
void initState() {
super.initState();
_initFuture =
widget.projectId != null ? _loadExisting() : Future.value();
}
@override
void dispose() {
_titleController.dispose();
_descController.dispose();
_goalController.dispose();
super.dispose();
}
Future<void> _loadExisting() async {
final projects = ref.read(projectsProvider).value ?? [];
final project =
projects.where((p) => p.id == widget.projectId).firstOrNull;
if (project != null) {
_titleController.text = project.title;
_descController.text = project.description ?? '';
_goalController.text = project.goal ?? '';
_status = project.status;
}
}
Future<void> _save() async {
final title = _titleController.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Title is required.')));
return;
}
setState(() => _saving = true);
try {
if (widget.projectId == null) {
await ref.read(projectsProvider.notifier).create(
title: title,
description: _descController.text.trim().isEmpty
? null
: _descController.text.trim(),
goal: _goalController.text.trim().isEmpty
? null
: _goalController.text.trim(),
);
} else {
await ref.read(projectsProvider.notifier).updateProject(
widget.projectId!,
{
'title': title,
'description': _descController.text.trim(),
'goal': _goalController.text.trim(),
'status': _status,
},
);
}
if (mounted) context.pop();
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _initFuture,
builder: (context, snapshot) => Scaffold(
appBar: AppBar(
title: Text(
widget.projectId == null ? 'New Project' : 'Edit Project'),
actions: [
IconButton(
icon: _saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(LucideIcons.check),
onPressed: _saving ? null : _save,
),
],
),
body: snapshot.connectionState == ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title *', border: OutlineInputBorder()),
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _descController,
decoration: const InputDecoration(
labelText: 'Description',
border: OutlineInputBorder()),
maxLines: 3,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextField(
controller: _goalController,
decoration: const InputDecoration(
labelText: 'Goal', border: OutlineInputBorder()),
maxLines: 2,
textInputAction: TextInputAction.done,
),
if (widget.projectId != null) ...[
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _status,
decoration: const InputDecoration(
labelText: 'Status',
border: OutlineInputBorder()),
items: const [
DropdownMenuItem(
value: 'active', child: Text('Active')),
DropdownMenuItem(
value: 'completed', child: Text('Completed')),
DropdownMenuItem(
value: 'archived', child: Text('Archived')),
],
onChanged: (v) => setState(() => _status = v!),
),
],
],
),
),
);
}
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/local/database.dart';
import '../../data/models/project.dart';
import '../../providers/projects_provider.dart';
import '../../widgets/pending_sync_badge.dart';
class ProjectsScreen extends ConsumerWidget {
const ProjectsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final projectsAsync = ref.watch(projectsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Projects')),
body: projectsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (projects) => projects.isEmpty
? const Center(child: Text('No projects yet.'))
: RefreshIndicator(
onRefresh: () => ref.read(projectsProvider.notifier).refresh(),
child: ListView.separated(
itemCount: projects.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (_, i) => _ProjectCard(project: projects[i]),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/projects/new'),
tooltip: 'New project',
child: const Icon(LucideIcons.plus),
),
);
}
}
class _ProjectCard extends StatelessWidget {
final Project project;
const _ProjectCard({required this.project});
Color _statusColor(BuildContext context) => switch (project.status) {
'completed' => Colors.blue,
'archived' => Colors.grey,
_ => Theme.of(context).colorScheme.primary,
};
@override
Widget build(BuildContext context) {
return ListTile(
title: Row(
children: [
Flexible(child: Text(project.title)),
PendingSyncBadge(domain: kSyncDomainProjects, id: project.id),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (project.description?.isNotEmpty == true)
Text(
project.description!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (project.goal?.isNotEmpty == true)
Text(
project.goal!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
],
),
trailing: Chip(
label: Text(
project.status,
style: TextStyle(
fontSize: 11,
color: _statusColor(context),
),
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onTap: () => context.push('/projects/${project.id}/tasks'),
);
}
}
+15 -14
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -24,28 +25,28 @@ class SettingsScreen extends ConsumerWidget {
ListTile(
title: const Text('Server URL'),
subtitle: Text(serverUrl ?? 'Not configured'),
leading: const Icon(Icons.dns),
leading: const Icon(LucideIcons.server),
onTap: () => context.go(Routes.setup),
),
const Divider(),
ListTile(
title: const Text('Appearance'),
leading: const Icon(Icons.brightness_6),
leading: const Icon(LucideIcons.sunMoon),
trailing: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.brightness_auto),
icon: Icon(LucideIcons.contrast),
tooltip: 'System',
),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
icon: Icon(LucideIcons.sun),
tooltip: 'Light',
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
icon: Icon(LucideIcons.moon),
tooltip: 'Dark',
),
],
@@ -58,7 +59,7 @@ class SettingsScreen extends ConsumerWidget {
// ── Updates ──────────────────────────────────────────────────────
ListTile(
leading: const Icon(Icons.update),
leading: const Icon(LucideIcons.refreshCw),
title: const Text('Update repository'),
subtitle: Text(repoUrl?.isNotEmpty == true
? repoUrl!
@@ -66,7 +67,7 @@ class SettingsScreen extends ConsumerWidget {
onTap: () => _editRepoUrl(context, ref, repoUrl),
),
ListTile(
leading: const Icon(Icons.info_outline),
leading: const Icon(LucideIcons.info),
title: const Text('App version'),
subtitle: _versionSubtitle(update),
trailing: update.status == UpdateStatus.checking
@@ -92,13 +93,13 @@ class SettingsScreen extends ConsumerWidget {
child: const Text('Check'),
),
),
if (update.status == UpdateStatus.available ||
if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading)
_UpdateTile(update: update),
if (update.status == UpdateStatus.error)
ListTile(
leading:
const Icon(Icons.error_outline, color: Colors.red),
const Icon(LucideIcons.alertCircle, color: Colors.red),
title: const Text('Update check failed'),
subtitle: Text(
update.errorMessage ?? 'Unknown error',
@@ -110,7 +111,7 @@ class SettingsScreen extends ConsumerWidget {
const Divider(),
ListTile(
title: const Text('Sign Out'),
leading: const Icon(Icons.logout),
leading: const Icon(LucideIcons.logOut),
onTap: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) context.go(Routes.login);
@@ -131,7 +132,7 @@ class SettingsScreen extends ConsumerWidget {
if (update.status == UpdateStatus.upToDate) {
return Text('v$current — up to date');
}
if (update.status == UpdateStatus.available ||
if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading) {
return Text('v$current installed');
}
@@ -184,7 +185,7 @@ class _UpdateTile extends ConsumerWidget {
final isDownloading = update.status == UpdateStatus.downloading;
return ListTile(
leading: const Icon(Icons.system_update, color: Colors.green),
leading: const Icon(LucideIcons.downloadCloud, color: Colors.green),
title: Text('v${update.latestVersion} available'),
subtitle: isDownloading
? Column(
@@ -202,12 +203,12 @@ class _UpdateTile extends ConsumerWidget {
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
],
)
: const Text('Tap to download and install'),
: const Text('Ready to install'),
trailing: isDownloading
? null
: FilledButton(
onPressed: () =>
ref.read(updateProvider.notifier).downloadAndInstall(),
ref.read(updateProvider.notifier).install(),
child: const Text('Install'),
),
);
+6 -1
View File
@@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
await ref.read(authProvider.notifier).verify();
if (!mounted) return;
final status = ref.read(authProvider);
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
if (status == AuthStatus.authenticated) {
context.go(Routes.briefing);
context.go(Routes.journal);
} else if (status == AuthStatus.offline && hasEverLoggedIn) {
// Server unreachable but this user has logged in before — land them on
// the briefing with the offline banner rather than the login screen.
context.go(Routes.journal);
} else {
context.go(Routes.login);
}
+10 -5
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/exceptions.dart';
import '../../core/theme.dart';
import '../../data/models/task.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/tasks_provider.dart';
@@ -111,6 +113,9 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete')),
],
),
@@ -190,7 +195,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
actions: [
if (widget.taskId != null)
IconButton(
icon: const Icon(Icons.delete),
icon: const Icon(LucideIcons.trash2),
onPressed: _delete,
),
IconButton(
@@ -200,7 +205,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
: const Icon(LucideIcons.check),
onPressed: _saving ? null : _save,
),
],
@@ -272,10 +277,10 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
title: Text(_dueDate == null
? 'No due date'
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
leading: const Icon(Icons.calendar_today),
leading: const Icon(LucideIcons.calendarDays),
trailing: _dueDate != null
? IconButton(
icon: const Icon(Icons.clear),
icon: const Icon(LucideIcons.x),
onPressed: () =>
setState(() => _dueDate = null),
)
@@ -331,7 +336,7 @@ class _SubTasksSection extends ConsumerWidget {
const Spacer(),
TextButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add'),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
+210 -30
View File
@@ -1,19 +1,38 @@
import 'dart:math' show min;
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/message.dart';
import '../providers/api_client_provider.dart';
import '../providers/settings_provider.dart';
import 'tool_call_chip.dart';
class ChatMessageBubble extends StatelessWidget {
class ChatMessageBubble extends ConsumerWidget {
final Message message;
const ChatMessageBubble({super.key, required this.message});
final String streamingStatus;
const ChatMessageBubble({
super.key,
required this.message,
this.streamingStatus = '',
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
final serverUrl = ref.watch(serverUrlProvider) ?? '';
final dio = ref.watch(dioProvider);
final isGenerating = message.status == 'generating';
final toolCalls = message.toolCalls ?? const [];
// An assistant bubble with no text, no tool calls, and still generating
// falls back to the spinner+status "waiting for the first token" view.
final showSpinnerOnly =
isGenerating && message.content.isEmpty && toolCalls.isEmpty;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
@@ -22,14 +41,16 @@ class ChatMessageBubble extends StatelessWidget {
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
),
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
// Illuminated Transcript pattern (mirrors web's ChatMessage.vue):
// - User bubble: transparent bg, neutral Pewter border, only the
// bottom-right corner clipped (the "from-me" tail).
// - Assistant bubble: card surface, 2px accent left edge (the
// "illuminated capital"), accent-tinted glow shadow + depth
// shadow, only the bottom-left corner clipped.
decoration: isUser
? BoxDecoration(
// Ghost style: transparent bg, thin border
color: Colors.transparent,
border: Border.all(
color: scheme.primary.withValues(alpha: 0.35),
width: 1,
),
border: Border.all(color: scheme.outline, width: 1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
@@ -38,42 +59,201 @@ class ChatMessageBubble extends StatelessWidget {
),
)
: BoxDecoration(
// Assistant: elevated surface + left accent border
color: scheme.surfaceContainerHighest,
color: scheme.surface,
border: Border(
left: BorderSide(color: scheme.primary, width: 2),
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
boxShadow: [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.14),
blurRadius: 28,
offset: const Offset(0, 4),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: isGenerating && message.content.isEmpty
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onSurfaceVariant,
),
)
: MarkdownBody(
data: message.content.isEmpty ? '' : message.content,
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? scheme.onSurface.withValues(alpha: 0.75)
: scheme.onSurface,
fontSize: 14,
),
),
child: showSpinnerOnly
? _buildSpinner(scheme)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Accumulated tool-call chips: visible both during
// streaming (fed live over SSE) and after reload (from
// the persisted message.tool_calls array).
if (toolCalls.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final tc in toolCalls) ToolCallChip(toolCall: tc),
],
),
const SizedBox(height: 6),
],
// Rolling status line — shows backend stage text
// ("Creating note", "Searching calendar") while the
// model is between tool rounds or just before the
// first token. Stays above any already-streamed text
// so the user can see what's happening mid-turn.
if (isGenerating && streamingStatus.isNotEmpty) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(width: 6),
Flexible(
child: Text(
streamingStatus,
style: TextStyle(
fontSize: 11,
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
],
),
const SizedBox(height: 6),
],
if (message.content.isNotEmpty)
MarkdownBody(
data: message.content,
imageBuilder: (uri, title, alt) {
return _AuthImage(
uri: uri,
alt: alt,
serverUrl: serverUrl,
dio: dio,
);
},
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
? scheme.onSurface.withValues(alpha: 0.75)
: scheme.onSurface,
fontSize: 14,
),
),
),
],
),
),
),
);
}
Widget _buildSpinner(ColorScheme scheme) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.onSurfaceVariant,
),
),
if (streamingStatus.isNotEmpty) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
streamingStatus,
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
],
],
);
}
}
class _AuthImage extends StatefulWidget {
final Uri uri;
final String? alt;
final String serverUrl;
final Dio dio;
const _AuthImage({
required this.uri,
this.alt,
required this.serverUrl,
required this.dio,
});
@override
State<_AuthImage> createState() => _AuthImageState();
}
class _AuthImageState extends State<_AuthImage> {
late Future<Uint8List> _future;
@override
void initState() {
super.initState();
_future = _fetchImage();
}
Future<Uint8List> _fetchImage() async {
var url = widget.uri.toString();
if (url.startsWith('/')) {
url = '${widget.serverUrl}$url';
}
final response = await widget.dio.get<List<int>>(
url,
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(
height: 100,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (snapshot.hasError || !snapshot.hasData) {
return Text(widget.alt ?? 'Image failed to load');
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
snapshot.data!,
fit: BoxFit.contain,
errorBuilder: (_, _, _) =>
Text(widget.alt ?? 'Image failed to load'),
),
);
},
);
}
}
@@ -1,26 +1,28 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart';
class BriefingDigestCard extends StatefulWidget {
/// The first assistant message from today's briefing, or null if none yet.
class JournalPrepCard extends StatefulWidget {
/// The first assistant message from today's journal — the daily prep
/// (LLM-generated briefing-style opener), or null if not yet generated.
final Message? message;
/// Called when the user taps "Generate now".
final VoidCallback? onGenerateNow;
const BriefingDigestCard({
const JournalPrepCard({
super.key,
required this.message,
this.onGenerateNow,
});
@override
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
State<JournalPrepCard> createState() => _JournalPrepCardState();
}
class _BriefingDigestCardState extends State<BriefingDigestCard> {
class _JournalPrepCardState extends State<JournalPrepCard> {
bool _expanded = false;
@override
@@ -43,10 +45,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Row(
children: [
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
Icon(LucideIcons.bookOpen, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Text(
_todayLabel(),
@@ -57,11 +58,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
],
),
const SizedBox(height: 10),
// Body
if (widget.message == null) ...[
Text(
'No briefing yet today.',
'No prep yet today.',
style: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../data/local/database.dart';
import '../data/models/knowledge_item.dart';
import 'pending_sync_badge.dart';
class KnowledgeItemCard extends StatelessWidget {
final KnowledgeItem item;
const KnowledgeItemCard({super.key, required this.item});
String get _pendingDomain =>
item.noteType == 'task' ? kSyncDomainTasks : kSyncDomainNotes;
IconData get _icon => switch (item.noteType) {
'person' => LucideIcons.user,
'place' => LucideIcons.mapPin,
'list' => LucideIcons.listChecks,
'task' => LucideIcons.checkCircle2,
_ => LucideIcons.fileText,
};
Color _statusColor(BuildContext context) {
if (item.noteType != 'task') return Theme.of(context).colorScheme.primary;
return switch (item.status) {
'done' => Colors.green,
'in_progress' => Colors.orange,
'cancelled' => Colors.grey,
_ => Theme.of(context).colorScheme.primary,
};
}
String? get _subtitle {
if (item.noteType == 'task') {
if (item.body.trim().isNotEmpty) {
final preview = item.body.trim().replaceAll('\n', ' ');
return preview.length > 200 ? '${preview.substring(0, 200)}' : preview;
}
if (item.dueDate != null) return 'Due ${item.dueDate}';
return item.status;
}
if (item.body.trim().isEmpty) return null;
final preview = item.body.trim().replaceAll('\n', ' ');
return preview.length > 200 ? '${preview.substring(0, 200)}' : preview;
}
void _onTap(BuildContext context) {
if (item.noteType == 'task') {
context.push('/tasks/${item.id}/edit');
} else {
context.push('/notes/${item.id}');
}
}
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(_icon, color: _statusColor(context)),
title: Row(
children: [
Flexible(
child: Text(
item.title.isEmpty ? '(untitled)' : item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
PendingSyncBadge(domain: _pendingDomain, id: item.id),
],
),
subtitle: _subtitle != null
? Text(
_subtitle!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
)
: null,
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
onTap: () => _onTap(context),
);
}
Widget buildGridCard(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: scheme.outlineVariant),
),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => _onTap(context),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(_icon, size: 18, color: _statusColor(context)),
const SizedBox(width: 8),
Expanded(
child: Text(
item.title.isEmpty ? '(untitled)' : item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
),
PendingSyncBadge(domain: _pendingDomain, id: item.id),
],
),
if (_subtitle != null) ...[
const SizedBox(height: 8),
Expanded(
child: Text(
_subtitle!,
overflow: TextOverflow.ellipsis,
maxLines: 4,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
height: 1.4,
),
),
),
],
if (item.tags.isNotEmpty) ...[
const Spacer(),
_TagChips(tags: item.tags),
],
],
),
),
),
);
}
}
class _TagChips extends StatelessWidget {
final List<String> tags;
const _TagChips({required this.tags});
@override
Widget build(BuildContext context) {
final shown = tags.take(2).toList();
final extra = tags.length - shown.length;
return Wrap(
spacing: 4,
children: [
for (final t in shown)
Chip(
label: Text(t, style: const TextStyle(fontSize: 10)),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
if (extra > 0)
Chip(
label: Text('+$extra', style: const TextStyle(fontSize: 10)),
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
],
);
}
}
-272
View File
@@ -1,272 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/constants.dart';
import '../data/models/note.dart';
import '../data/models/project.dart';
import '../data/models/task.dart';
import '../providers/tasks_provider.dart';
// ── Note card ────────────────────────────────────────────────────────────────
class NoteLibraryCard extends StatelessWidget {
final Note note;
const NoteLibraryCard({super.key, required this.note});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => context
.push(Routes.noteDetail.replaceFirst(':id', '${note.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.article_outlined,
size: 15, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(
note.title.isNotEmpty ? note.title : 'Untitled',
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
_relativeTime(note.updatedAt),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
),
],
),
if (note.body.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
note.body.replaceAll('\n', ' '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
if (note.tags.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 2,
children: note.tags
.take(4)
.map((t) => Chip(
label: Text(t),
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
))
.toList(),
),
],
],
),
),
),
);
}
}
// ── Task card ────────────────────────────────────────────────────────────────
class TaskLibraryCard extends ConsumerWidget {
final Task task;
const TaskLibraryCard({super.key, required this.task});
Color _priorityColor(BuildContext context) {
return switch (task.priority) {
TaskPriority.high => const Color(0xFFEF4444),
TaskPriority.medium => const Color(0xFFF59E0B),
_ => Theme.of(context).colorScheme.onSurfaceVariant,
};
}
IconData get _statusIcon => switch (task.status) {
TaskStatus.done => Icons.check_circle,
TaskStatus.inProgress => Icons.timelapse,
_ => Icons.radio_button_unchecked,
};
Color _statusColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (task.status) {
TaskStatus.done => const Color(0xFF22C55E),
TaskStatus.inProgress => cs.primary,
_ => cs.onSurfaceVariant,
};
}
TaskStatus get _nextStatus => switch (task.status) {
TaskStatus.todo => TaskStatus.inProgress,
TaskStatus.inProgress => TaskStatus.done,
_ => TaskStatus.todo,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => context
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 14, 10),
child: Row(
children: [
// Status cycle button
IconButton(
icon: Icon(_statusIcon, color: _statusColor(context)),
onPressed: () => ref
.read(tasksProvider.notifier)
.updateTask(task.id, {'status': _nextStatus.value}),
tooltip: 'Cycle status',
visualDensity: VisualDensity.compact,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: task.status == TaskStatus.done
? TextDecoration.lineThrough
: null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (task.dueDate != null) ...[
const SizedBox(height: 2),
Text(
'Due ${_formatDate(task.dueDate!)}',
style: theme.textTheme.labelSmall?.copyWith(
color: task.dueDate!.isBefore(DateTime.now()) &&
task.status != TaskStatus.done
? const Color(0xFFEF4444)
: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
if (task.priority != TaskPriority.none &&
task.priority != TaskPriority.low)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _priorityColor(context),
shape: BoxShape.circle,
),
),
],
),
),
),
);
}
}
// ── Project card ─────────────────────────────────────────────────────────────
class ProjectLibraryCard extends StatelessWidget {
final Project project;
const ProjectLibraryCard({super.key, required this.project});
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF6366F1);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = _parseColor(project.color);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context
.push('/projects/${project.id}/tasks'),
borderRadius: BorderRadius.circular(14),
child: Row(
children: [
// Colour strip
Container(width: 6, height: 64, color: color),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.title,
style: theme.textTheme.titleSmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (project.description?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(
project.description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
const SizedBox(width: 12),
],
),
),
);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'just now';
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
if (diff.inDays < 1) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return _formatDate(dt);
}
String _formatDate(DateTime dt) {
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
return '${months[dt.month - 1]} ${dt.day}';
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/api_client_provider.dart';
import '../providers/auth_provider.dart';
/// Sticky banner shown when the backend is unreachable. Surfaces a retry
/// action plus a "last sync X ago" hint when there is cached content to
/// fall back on (Tier 2 offline mode).
class OfflineBanner extends ConsumerStatefulWidget {
const OfflineBanner({super.key});
@override
ConsumerState<OfflineBanner> createState() => _OfflineBannerState();
}
class _OfflineBannerState extends ConsumerState<OfflineBanner> {
bool _retrying = false;
DateTime? _lastSync;
Timer? _refreshTimer;
@override
void initState() {
super.initState();
_loadLastSync();
// Refresh the relative-time string every 30s so "5 min ago" rolls
// forward without the user having to interact with the banner.
_refreshTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _loadLastSync(),
);
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
Future<void> _loadLastSync() async {
if (!mounted) return;
try {
// Most-recent sync across all cached domains so the hint reads as
// "your data was current as of X" regardless of which screen the
// user is looking at.
final ts = await ref.read(fabledDatabaseProvider).getLatestSync();
if (!mounted) return;
setState(() => _lastSync = ts);
} catch (_) {
// Non-critical — banner just won't show the sync hint.
}
}
Future<void> _retry() async {
if (_retrying) return;
setState(() => _retrying = true);
try {
await ref.read(authProvider.notifier).verify();
} finally {
if (mounted) setState(() => _retrying = false);
}
}
String? _relativeAgo(DateTime ts) {
final diff = DateTime.now().difference(ts);
if (diff.isNegative) return null;
if (diff.inMinutes < 1) return 'just now';
if (diff.inMinutes < 60) return '${diff.inMinutes} min ago';
if (diff.inHours < 24) {
final h = diff.inHours;
return '$h ${h == 1 ? 'hour' : 'hours'} ago';
}
final d = diff.inDays;
return '$d ${d == 1 ? 'day' : 'days'} ago';
}
@override
Widget build(BuildContext context) {
final status = ref.watch(authProvider);
if (status != AuthStatus.offline) return const SizedBox.shrink();
final scheme = Theme.of(context).colorScheme;
final ago = _lastSync == null ? null : _relativeAgo(_lastSync!);
// Pending queue depth — shown on the Retry button so the user knows
// there's offline work waiting to land when they come back online.
final pending = ref.watch(writeQueueDepthProvider).asData?.value ?? 0;
final baseMessage = ago == null
? 'Offline — showing cached data.'
: 'Offline — last sync $ago.';
final message = pending > 0
? '$baseMessage $pending pending change${pending == 1 ? '' : 's'}.'
: baseMessage;
final retryLabel = pending > 0 ? 'Retry ($pending)' : 'Retry';
return Material(
color: scheme.errorContainer,
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(LucideIcons.cloudOff,
size: 18, color: scheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: TextStyle(color: scheme.onErrorContainer),
),
),
TextButton(
onPressed: _retrying ? null : _retry,
style: TextButton.styleFrom(
foregroundColor: scheme.onErrorContainer,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
child: _retrying
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(retryLabel),
),
],
),
),
),
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/local/database.dart';
import '../providers/api_client_provider.dart';
/// Small cloud-upload glyph shown next to a row whose id has a queued
/// offline write (Phase 4). Renders nothing when the queue is empty for
/// that id, so list views stay clean during normal online operation.
class PendingSyncBadge extends ConsumerWidget {
final String domain;
final int id;
final double size;
const PendingSyncBadge({
super.key,
required this.domain,
required this.id,
this.size = 14,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pending = switch (domain) {
kSyncDomainNotes => ref.watch(pendingNoteIdsProvider).asData?.value,
kSyncDomainTasks => ref.watch(pendingTaskIdsProvider).asData?.value,
kSyncDomainProjects =>
ref.watch(pendingProjectIdsProvider).asData?.value,
_ => null,
};
if (pending == null || !pending.contains(id)) {
return const SizedBox.shrink();
}
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Pending sync — will save when online',
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Icon(
LucideIcons.uploadCloud,
size: size,
color: scheme.onSurfaceVariant,
),
),
);
}
}
+182
View File
@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../core/constants.dart';
/// Human-readable labels for each backend tool. Kept in sync with
/// `_TOOL_LABELS` in `fabledassistant/services/generation_task.py` so the
/// mobile chips use the same wording as the web ToolCallCard status pills.
const Map<String, String> _toolLabels = {
'create_note': 'Created note',
'update_note': 'Updated note',
'delete_note': 'Deleted note',
'create_task': 'Created task',
'update_task': 'Updated task',
'delete_task': 'Deleted task',
'read_note': 'Read note',
'list_notes': 'Listed notes',
'list_tasks': 'Searched tasks',
'search_notes': 'Searched notes',
'create_event': 'Created event',
'list_events': 'Searched calendar',
'search_events': 'Searched calendar',
'update_event': 'Updated event',
'delete_event': 'Removed event',
'list_calendars': 'Listed calendars',
'search_web': 'Searched the web',
'research_topic': 'Researched topic',
'search_images': 'Searched images',
'create_project': 'Created project',
'update_project': 'Updated project',
'list_projects': 'Listed projects',
'get_project': 'Read project',
'search_projects': 'Searched projects',
'create_milestone': 'Created milestone',
'update_milestone': 'Updated milestone',
'list_milestones': 'Listed milestones',
'set_rag_scope': 'Changed knowledge scope',
'calculate': 'Calculated',
'read_article': 'Read article',
'get_profile': 'Read profile',
'update_profile': 'Updated profile',
'update_person': 'Updated person',
'update_place': 'Updated place',
'add_task_log': 'Logged task progress',
};
IconData _iconFor(String fn) {
if (fn.contains('note')) return LucideIcons.stickyNote;
if (fn.contains('task')) return LucideIcons.checkCircle2;
if (fn.contains('event') || fn.contains('calendar')) {
return LucideIcons.calendarCheck;
}
if (fn.contains('project')) return LucideIcons.folder;
if (fn.contains('milestone')) return LucideIcons.flag;
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
return LucideIcons.globe;
}
if (fn.contains('image')) return LucideIcons.image;
if (fn.contains('person') || fn.contains('profile')) {
return LucideIcons.user;
}
if (fn.contains('place')) return LucideIcons.mapPin;
if (fn.contains('rag') || fn.contains('scope')) return LucideIcons.sliders;
if (fn.contains('calculate')) return LucideIcons.calculator;
return LucideIcons.sparkles;
}
/// Pull the destination route for this tool call from its `result` payload,
/// if the tool produced something we can navigate to. Returns `null` for
/// read-only / no-target tools so the chip stays visible but non-tappable.
///
/// Backend tool results use the shape:
/// `{success, type: "note"|"task"|"event"|"project"|..., data: {id, ...}}`
/// which is defined alongside each tool handler (see
/// `services/tools/notes.py`, `calendar.py`, etc.).
String? _routeForToolCall(Map<String, dynamic> tc) {
final result = tc['result'];
if (result is! Map<String, dynamic>) return null;
if (result['success'] != true) return null;
final type = result['type'] as String?;
final data = result['data'];
if (type == null || data is! Map<String, dynamic>) return null;
final id = data['id'];
if (id is! int) return null;
switch (type) {
case 'note':
return Routes.noteDetail.replaceFirst(':id', '$id');
case 'task':
return Routes.taskEdit.replaceFirst(':id', '$id');
case 'event':
case 'event_updated':
// No single-event route on mobile — fall back to the calendar.
return Routes.calendar;
case 'project':
return Routes.projectTasks.replaceFirst(':id', '$id');
default:
return null;
}
}
/// Small status pill rendered inside an assistant message bubble for each
/// tool invocation. Mirrors the web app's ToolCallCard header at a glance —
/// icon + label + success/error tint — and, when the tool produced a
/// navigable entity (note, task, event, project), tapping the chip opens it.
class ToolCallChip extends StatelessWidget {
final Map<String, dynamic> toolCall;
const ToolCallChip({super.key, required this.toolCall});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final function = (toolCall['function'] as String?) ?? 'tool';
final status = (toolCall['status'] as String?) ?? 'success';
final isError = status == 'error';
final label = _toolLabels[function] ?? function;
final route = isError ? null : _routeForToolCall(toolCall);
final bg = isError
? scheme.errorContainer.withValues(alpha: 0.55)
: scheme.primary.withValues(alpha: 0.12);
final fg = isError ? scheme.onErrorContainer : scheme.primary;
// Pull the entity title from the result payload so the chip can show
// "Created note: Grocery List" instead of a generic label. Falls back
// to the generic label when the tool didn't return a titled entity.
String? entityTitle;
final result = toolCall['result'];
if (result is Map<String, dynamic>) {
final data = result['data'];
if (data is Map<String, dynamic>) {
final t = data['title'];
if (t is String && t.isNotEmpty) entityTitle = t;
}
}
final displayText =
entityTitle != null ? '$label: $entityTitle' : label;
final chip = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: fg.withValues(alpha: 0.35), width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(_iconFor(function), size: 13, color: fg),
const SizedBox(width: 5),
Flexible(
child: Text(
displayText,
style: TextStyle(
fontSize: 11,
color: fg,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (route != null) ...[
const SizedBox(width: 4),
Icon(LucideIcons.arrowRight, size: 11, color: fg),
],
],
),
);
if (route == null) return chip;
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => context.push(route),
child: chip,
),
);
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/voice_provider.dart';
/// Animated mic button that reflects the current [VoiceMode].
///
/// - idle: muted background, mic_none icon
/// - recording: red, pulses with live [amplitude] for real-time feedback
/// - transcribing: indigo with spinner
/// - playing: indigo with volume_up icon
class VoiceMicButton extends StatelessWidget {
final VoiceMode mode;
final bool voiceModeActive;
/// Live mic amplitude 0.01.0 while recording. Drives the button scale
/// and glow so the user has obvious feedback that audio is being picked
/// up. Ignored when not in [VoiceMode.recording].
final double amplitude;
final VoidCallback? onTap;
const VoiceMicButton({
super.key,
required this.mode,
required this.voiceModeActive,
this.amplitude = 0.0,
this.onTap,
});
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (mode) {
VoiceMode.recording => const Color(0xFFEF4444),
VoiceMode.transcribing || VoiceMode.playing => cs.primary,
VoiceMode.idle => cs.surfaceContainerHighest,
};
}
Widget _icon(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor =
mode == VoiceMode.idle ? cs.onSurfaceVariant : Colors.white;
return switch (mode) {
VoiceMode.transcribing => SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: iconColor,
),
),
VoiceMode.playing => Icon(LucideIcons.volume2, color: iconColor, size: 20),
_ => Icon(LucideIcons.mic, color: iconColor, size: 20),
};
}
@override
Widget build(BuildContext context) {
final isRecording = mode == VoiceMode.recording;
final button = Material(
color: _bgColor(context),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 40,
height: 40,
child: Center(child: _icon(context)),
),
),
);
if (!isRecording) return button;
// Base pulse so silence still breathes (0.1 floor), scale + glow climb
// linearly with live amplitude.
final amp = amplitude.clamp(0.0, 1.0);
final pulse = 0.1 + amp * 0.9;
return AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFFEF4444).withValues(alpha: 0.2 + pulse * 0.3),
blurRadius: 6 + pulse * 14,
spreadRadius: 1 + pulse * 5,
),
],
),
child: AnimatedScale(
scale: 1.0 + pulse * 0.18,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
child: button,
),
);
}
}
-185
View File
@@ -1,185 +0,0 @@
import 'package:flutter/material.dart';
class WeatherCard extends StatelessWidget {
final Map<String, dynamic>? weather;
const WeatherCard({super.key, required this.weather});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (weather == null) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant),
),
child: Text(
'Weather data unavailable — will retry at next slot.',
style: TextStyle(
color: scheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
fontSize: 13,
),
),
);
}
final w = weather!;
final location = w['location'] as String? ?? '';
final currentTemp = w['current_temp'];
final condition = w['condition'] as String? ?? '';
final todayHigh = w['today_high'];
final todayLow = w['today_low'];
final yesterdayHigh = w['yesterday_high'];
final fetchedAt = w['fetched_at'] as String?;
final forecast = (w['forecast'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
String? tempDelta;
if (todayHigh != null && yesterdayHigh != null) {
final diff = (todayHigh as num) - (yesterdayHigh as num);
if (diff.abs() < 1) {
tempDelta = 'Same as yesterday';
} else {
final dir = diff > 0 ? 'warmer' : 'cooler';
tempDelta = '${diff.abs().round()}° $dir than yesterday';
}
}
String? fetchedLabel;
if (fetchedAt != null) {
try {
final dt = DateTime.parse(fetchedAt).toLocal();
final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12;
final m = dt.minute.toString().padLeft(2, '0');
final period = dt.hour < 12 ? 'AM' : 'PM';
fetchedLabel = '$h:$m $period';
} catch (_) {}
}
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: location + fetched time
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
location,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: scheme.onSurface,
),
),
if (fetchedLabel != null)
Text(
'as of $fetchedLabel',
style: TextStyle(
fontSize: 12,
color: scheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
// Current temp + condition
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
'$currentTemp°',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
color: scheme.onSurface,
height: 1,
),
),
const SizedBox(width: 10),
Text(
condition,
style: TextStyle(
fontSize: 14,
color: scheme.onSurfaceVariant,
),
),
],
),
// Today high/low + delta
if (todayHigh != null) ...[
const SizedBox(height: 8),
Text(
'Today: $todayHigh° / $todayLow°'
'${tempDelta != null ? ' · $tempDelta' : ''}',
style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant),
),
],
// Forecast strip
if (forecast.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: forecast.map((day) {
return SizedBox(
width: 64,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
day['day'] as String? ?? '',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: scheme.onSurface,
),
),
const SizedBox(height: 3),
Text(
day['condition'] as String? ?? '',
style: TextStyle(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
'${day['high']}° / ${day['low']}°',
style: TextStyle(
fontSize: 12,
color: scheme.onSurface,
),
),
],
),
);
}).toList(),
),
),
],
],
),
);
}
}
@@ -6,10 +6,26 @@
#include "generated_plugin_registrant.h"
#include <flutter_timezone/flutter_timezone_plugin.h>
#include <open_file_linux/open_file_linux_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_timezone_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin");
flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar);
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
+5
View File
@@ -3,10 +3,15 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_timezone
open_file_linux
record_linux
sqlite3_flutter_libs
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
)
set(PLUGIN_BUNDLED_LIBRARIES)
@@ -5,14 +5,26 @@
import FlutterMacOS
import Foundation
import audio_session
import flutter_inappwebview_macos
import flutter_timezone
import just_audio
import open_file_mac
import package_info_plus
import record_macos
import shared_preferences_foundation
import sqlite3_flutter_libs
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+377 -1
View File
@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audio_session:
dependency: transitive
description:
name: audio_session
sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
url: "https://pub.dev"
source: hosted
version: "0.1.25"
boolean_selector:
dependency: transitive
description:
@@ -49,6 +57,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "4.0.6"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "22fdcc3cfeb9d974d7408718c4be15ec5e9b1b350088f3a6c88f154e74dd700d"
url: "https://pub.dev"
source: hosted
version: "2.14.1"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
characters:
dependency: transitive
description:
@@ -57,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -145,6 +209,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev"
source: hosted
version: "3.1.7"
dio:
dependency: "direct main"
description:
@@ -169,6 +241,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
drift:
dependency: "direct main"
description:
name: drift
sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5"
url: "https://pub.dev"
source: hosted
version: "2.32.1"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91"
url: "https://pub.dev"
source: hosted
version: "2.32.1"
equatable:
dependency: transitive
description:
@@ -201,6 +289,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -352,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.0.2"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks:
dependency: transitive
description:
@@ -392,6 +496,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.0"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
@@ -408,6 +520,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.11.0"
just_audio:
dependency: "direct main"
description:
name: just_audio
sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e
url: "https://pub.dev"
source: hosted
version: "0.9.46"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev"
source: hosted
version: "0.4.16"
leak_tracker:
dependency: transitive
description:
@@ -448,6 +584,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
lucide_icons:
dependency: "direct main"
description:
name: lucide_icons
sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4
url: "https://pub.dev"
source: hosted
version: "0.257.0"
markdown:
dependency: "direct main"
description:
@@ -601,7 +745,7 @@ packages:
source: hosted
version: "3.2.1"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@@ -752,6 +896,86 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record:
dependency: "direct main"
description:
name: record
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev"
source: hosted
version: "6.2.0"
record_android:
dependency: transitive
description:
name: record_android
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
riverpod:
dependency: transitive
description:
@@ -760,6 +984,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
@@ -848,11 +1080,27 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
simple_gesture_detector:
dependency: transitive
description:
name: simple_gesture_detector
sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3
url: "https://pub.dev"
source: hosted
version: "0.2.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "4.2.3"
source_map_stack_trace:
dependency: transitive
description:
@@ -877,6 +1125,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.5.42"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b
url: "https://pub.dev"
source: hosted
version: "0.44.3"
stack_trace:
dependency: transitive
description:
@@ -901,6 +1173,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -909,6 +1189,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
table_calendar:
dependency: "direct main"
description:
name: table_calendar
sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
term_glyph:
dependency: transitive
description:
@@ -957,6 +1253,86 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.1"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vad:
dependency: "direct main"
description:
name: vad
sha256: ef6c8b12c5af7a6a519ff5684f074b8a2ac00c434705f544af379ea77bccd258
url: "https://pub.dev"
source: hosted
version: "0.0.7+1"
vector_math:
dependency: transitive
description:
+15
View File
@@ -12,6 +12,7 @@ dependencies:
sdk: flutter
cupertino_icons: ^1.0.8
lucide_icons: ^0.257.0
flutter_riverpod: ^3.3.1
go_router: ^17.1.0
dio: ^5.6.0
@@ -27,12 +28,26 @@ dependencies:
flutter_markdown_plus: ^1.0.7
google_fonts: ^8.0.2
flutter_timezone: ^5.0.2
url_launcher: ^6.3.1
record: ^6.2.0
vad: ^0.0.7
just_audio: ^0.9.39
table_calendar: ^3.1.2
# Tier 2 offline mode — local SQL store via Drift (sqlite3 under the hood).
# Drift was preferred over Hive in the design since the repo already mirrors
# well-defined backend schemas (notes/tasks/projects/etc.) one-to-one.
drift: ^2.20.0
sqlite3_flutter_libs: ^0.5.24
path: ^1.9.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.3
drift_dev: ^2.20.0
build_runner: ^2.4.13
flutter_launcher_icons:
android: true
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Publish a signed release APK to Forgejo.
#
# Finds the release matching $TAG (creating it if the CI job got here
# before the UI did), then attaches the APK as a release asset.
#
# Required env:
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
# TAG — release tag (e.g. v26.04.11)
# APK — path to the built APK
#
# Optional env:
# FORGEJO_API — defaults to the FabledApp repo API root
#
# Exits non-zero if the release can't be created or the asset upload
# fails. Designed to be testable locally:
# RELEASE_TOKEN=... TAG=v0.0.1 APK=/tmp/test.apk bash -x scripts/publish_apk_release.sh
set -euo pipefail
: "${RELEASE_TOKEN:?RELEASE_TOKEN not set}"
: "${TAG:?TAG not set}"
: "${APK:?APK not set}"
if [ ! -f "$APK" ]; then
echo "APK not found at $APK" >&2
exit 1
fi
API="${FORGEJO_API:-https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp}"
# jq isn't in the cirruslabs Flutter image, so we parse JSON with grep.
# Fragile but bounded: we only care about the first "id": <n> field,
# which is always the release id in Forgejo's responses for these
# endpoints. If Forgejo ever adds a preceding id field, revisit.
extract_id() {
echo "$1" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+'
}
# Look for an existing release (created via the UI or a prior run).
# curl -f turns 4xx/5xx into non-zero so we can distinguish "not found"
# (no release yet) from "auth broken" (real failure).
existing=$(curl -fsS -H "Authorization: token $RELEASE_TOKEN" \
"$API/releases/tags/$TAG" 2>/dev/null || true)
release_id=$(extract_id "$existing")
if [ -z "$release_id" ]; then
echo "No existing release for $TAG — creating one..."
response=$(curl -fsS -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"\"}" \
"$API/releases")
release_id=$(extract_id "$response")
if [ -z "$release_id" ]; then
echo "Failed to create release. API response:" >&2
echo "$response" >&2
exit 1
fi
echo "Created release $TAG (id=$release_id)."
else
echo "Found existing release $TAG (id=$release_id). Attaching APK..."
fi
curl -fsS -X POST \
-H "Authorization: token $RELEASE_TOKEN" \
-F "attachment=@$APK" \
"$API/releases/$release_id/assets" > /dev/null
echo "Done — $TAG is live at:"
echo "https://git.fabledsword.com/bvandeusen/FabledApp/releases/tag/$TAG"
+238 -3
View File
@@ -1,8 +1,243 @@
// Placeholder — integration tests go here once the app is running on device.
import 'package:fabled_app/data/models/calendar_event.dart';
import 'package:fabled_app/data/api/voice_api.dart';
import 'package:fabled_app/data/repositories/write_queue.dart';
import 'package:fabled_app/providers/voice_provider.dart';
import 'package:fabled_app/data/models/knowledge_item.dart';
import 'package:fabled_app/data/models/note.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder', () {
expect(true, isTrue);
group('Note.fromJson', () {
test('parses noteType when present', () {
final json = {
'id': 1,
'title': 'Alice',
'body': '',
'tags': <dynamic>[],
'note_type': 'person',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
};
final note = Note.fromJson(json);
expect(note.noteType, equals('person'));
});
test('defaults noteType to note when absent', () {
final json = {
'id': 2,
'title': 'My note',
'body': '',
'tags': <dynamic>[],
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
};
final note = Note.fromJson(json);
expect(note.noteType, equals('note'));
});
});
group('KnowledgeItem.fromJson', () {
test('parses a task item with task fields', () {
final json = {
'id': 10,
'note_type': 'task',
'title': 'Do the thing',
'body': '',
'tags': <dynamic>[],
'status': 'todo',
'priority': 'high',
'due_date': '2025-01-01',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
};
final item = KnowledgeItem.fromJson(json);
expect(item.noteType, equals('task'));
expect(item.status, equals('todo'));
expect(item.priority, equals('high'));
});
test('defaults noteType to note when absent', () {
final json = {
'id': 11,
'title': 'A note',
'body': '',
'tags': <dynamic>[],
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
};
final item = KnowledgeItem.fromJson(json);
expect(item.noteType, equals('note'));
expect(item.status, isNull);
});
});
group('VoiceStatus.fromJson', () {
test('parses enabled with stt and tts available', () {
final status = VoiceStatus.fromJson({
'enabled': true,
'stt': true,
'tts': true,
});
expect(status.enabled, isTrue);
expect(status.stt, isTrue);
expect(status.tts, isTrue);
});
test('parses disabled state', () {
final status = VoiceStatus.fromJson({
'enabled': false,
'stt': false,
'tts': false,
});
expect(status.enabled, isFalse);
});
test('fullyAvailable is false when enabled but stt is false', () {
final status = VoiceStatus.fromJson({
'enabled': true,
'stt': false,
'tts': true,
});
expect(status.fullyAvailable, isFalse);
});
});
group('VoiceNotifier sentence extraction', () {
test('extracts complete sentences at full stops', () {
final result = extractSentences('Hello world. How are you? I am fine!');
expect(result.sentences,
equals(['Hello world.', 'How are you?', 'I am fine!']));
expect(result.remainder, equals(''));
});
test('leaves incomplete fragment in remainder', () {
final result = extractSentences('Hello world. Incomplete');
expect(result.sentences, equals(['Hello world.']));
expect(result.remainder, equals('Incomplete'));
});
test('returns empty sentences and full text when no boundary', () {
final result = extractSentences('No boundary here');
expect(result.sentences, isEmpty);
expect(result.remainder, equals('No boundary here'));
});
});
group('VoiceNotifier markdown stripping', () {
test('strips code fences', () {
expect(stripMarkdownForTts('Before\n```dart\ncode\n```\nAfter'),
equals('Before After'));
});
test('strips bold and italic markers', () {
expect(stripMarkdownForTts('**bold** and *italic*'),
equals('bold and italic'));
});
test('strips headers', () {
expect(stripMarkdownForTts('## Section title'), equals('Section title'));
});
test('keeps link text, removes URL', () {
expect(stripMarkdownForTts('[click here](https://example.com)'),
equals('click here'));
});
test('strips list markers', () {
expect(stripMarkdownForTts('- item one\n- item two'),
equals('item one item two'));
});
});
group('CalendarEvent.fromJson', () {
test('parses all fields', () {
final json = {
'id': 10,
'title': 'Team meeting',
'start_dt': '2026-04-07T09:00:00+00:00',
'end_dt': '2026-04-07T10:00:00+00:00',
'all_day': false,
'description': 'Weekly sync',
'location': 'Room 4',
'color': '#6366F1',
'recurrence': 'FREQ=WEEKLY',
'project_id': 3,
'reminder_minutes': 15,
};
final event = CalendarEvent.fromJson(json);
expect(event.id, equals(10));
expect(event.title, equals('Team meeting'));
expect(event.startDt, equals(DateTime.parse('2026-04-07T09:00:00+00:00').toLocal()));
expect(event.endDt, equals(DateTime.parse('2026-04-07T10:00:00+00:00').toLocal()));
expect(event.allDay, isFalse);
expect(event.description, equals('Weekly sync'));
expect(event.location, equals('Room 4'));
expect(event.color, equals('#6366F1'));
expect(event.recurrence, equals('FREQ=WEEKLY'));
expect(event.projectId, equals(3));
expect(event.reminderMinutes, equals(15));
});
test('handles null optional fields', () {
final json = {
'id': 11,
'title': 'Birthday',
'start_dt': '2026-05-01T00:00:00+00:00',
'end_dt': null,
'all_day': true,
'description': '',
'location': '',
'color': '',
'recurrence': null,
'project_id': null,
'reminder_minutes': null,
};
final event = CalendarEvent.fromJson(json);
expect(event.endDt, isNull);
expect(event.allDay, isTrue);
expect(event.recurrence, isNull);
expect(event.projectId, isNull);
expect(event.reminderMinutes, isNull);
});
});
group('dateOnly', () {
test('strips time from datetime', () {
final dt = DateTime(2026, 4, 7, 14, 30, 45);
expect(dateOnly(dt), equals(DateTime(2026, 4, 7)));
});
});
group('QueueFailure.message', () {
test('overwritten with title quotes the title', () {
final f = QueueFailure(
reason: QueueFailureReason.overwritten,
domain: 'notes',
title: 'Grocery list',
);
expect(f.message, contains('"Grocery list"'));
expect(f.message, contains('overwritten'));
});
test('rejected without title falls back to generic phrasing', () {
final f = QueueFailure(
reason: QueueFailureReason.rejected,
domain: 'tasks',
detail: 'title required',
);
expect(f.message, contains('an offline edit'));
expect(f.message, contains('title required'));
});
test('missing communicates server-side deletion', () {
final f = QueueFailure(
reason: QueueFailureReason.missing,
domain: 'projects',
title: 'Q2 launch',
);
expect(f.message, contains('"Q2 launch"'));
expect(f.message, contains('deleted on the server'));
});
});
}
@@ -7,8 +7,23 @@
#include "generated_plugin_registrant.h"
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
FlutterTimezonePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
+6
View File
@@ -4,9 +4,15 @@
list(APPEND FLUTTER_PLUGIN_LIST
flutter_inappwebview_windows
flutter_timezone
permission_handler_windows
record_windows
sqlite3_flutter_libs
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
)
set(PLUGIN_BUNDLED_LIBRARIES)