Compare commits

...

23 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
68 changed files with 10685 additions and 2083 deletions
+123 -141
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -6,20 +7,20 @@ import 'package:flutter_timezone/flutter_timezone.dart';
import 'core/constants.dart'; import 'core/constants.dart';
import 'core/theme.dart'; import 'core/theme.dart';
import 'data/repositories/write_queue.dart';
import 'providers/api_client_provider.dart'; import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'core/exceptions.dart'; import 'core/exceptions.dart';
import 'providers/capture_queue_provider.dart'; import 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart'; import 'providers/capture_work_queue_provider.dart';
import 'providers/briefing_provider.dart';
import 'providers/calendar_provider.dart'; import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart'; import 'providers/chat_provider.dart';
import 'providers/journal_provider.dart';
import 'providers/knowledge_provider.dart'; import 'providers/knowledge_provider.dart';
import 'providers/news_provider.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'providers/update_provider.dart'; import 'providers/update_provider.dart';
import 'screens/auth/login_screen.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/knowledge/knowledge_screen.dart';
import 'screens/library/project_tasks_screen.dart'; import 'screens/library/project_tasks_screen.dart';
import 'screens/chat/chat_screen.dart'; import 'screens/chat/chat_screen.dart';
@@ -29,12 +30,12 @@ import 'screens/projects/project_edit_screen.dart';
import 'screens/projects/projects_screen.dart'; import 'screens/projects/projects_screen.dart';
import 'screens/notes/note_edit_screen.dart'; import 'screens/notes/note_edit_screen.dart';
import 'screens/settings/settings_screen.dart'; import 'screens/settings/settings_screen.dart';
import 'screens/news/news_screen.dart';
import 'screens/setup/setup_screen.dart'; import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart'; import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart'; import 'screens/tasks/task_edit_screen.dart';
import 'screens/calendar/calendar_screen.dart'; import 'screens/calendar/calendar_screen.dart';
import 'providers/voice_provider.dart'; import 'providers/voice_provider.dart';
import 'widgets/offline_banner.dart';
import 'widgets/voice_mic_button.dart'; import 'widgets/voice_mic_button.dart';
// ChangeNotifier that fires when auth or server URL changes, // ChangeNotifier that fires when auth or server URL changes,
@@ -44,6 +45,7 @@ class _RouterNotifier extends ChangeNotifier {
_RouterNotifier(Ref ref) { _RouterNotifier(Ref ref) {
ref.listen(authProvider, (_, _) => notifyListeners()); ref.listen(authProvider, (_, _) => notifyListeners());
ref.listen(serverUrlProvider, (_, _) => notifyListeners()); ref.listen(serverUrlProvider, (_, _) => notifyListeners());
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
} }
} }
@@ -57,6 +59,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final location = state.matchedLocation; final location = state.matchedLocation;
final serverUrl = ref.read(serverUrlProvider); final serverUrl = ref.read(serverUrlProvider);
final authStatus = ref.read(authProvider); final authStatus = ref.read(authProvider);
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
if (serverUrl == null || serverUrl.isEmpty) { if (serverUrl == null || serverUrl.isEmpty) {
if (location != Routes.setup) return Routes.setup; if (location != Routes.setup) return Routes.setup;
@@ -70,6 +73,16 @@ final routerProvider = Provider<GoRouter>((ref) {
return null; 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; return null;
}, },
routes: [ routes: [
@@ -148,8 +161,8 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state, child) => _Shell(child: child), builder: (context, state, child) => _Shell(child: child),
routes: [ routes: [
GoRoute( GoRoute(
path: Routes.briefing, path: Routes.journal,
builder: (_, _) => const BriefingScreen(), builder: (_, _) => const JournalScreen(),
), ),
GoRoute( GoRoute(
path: Routes.knowledge, path: Routes.knowledge,
@@ -159,21 +172,17 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.conversations, path: Routes.conversations,
builder: (_, _) => const ConversationsTabScreen(), builder: (_, _) => const ConversationsTabScreen(),
), ),
],
),
GoRoute( GoRoute(
path: Routes.projects, path: Routes.projects,
builder: (_, _) => const ProjectsScreen(), builder: (_, _) => const ProjectsScreen(),
), ),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute( GoRoute(
path: Routes.calendar, path: Routes.calendar,
builder: (_, _) => const CalendarScreen(), builder: (_, _) => const CalendarScreen(),
), ),
], ],
),
],
); );
}); });
@@ -186,10 +195,16 @@ class _Shell extends ConsumerStatefulWidget {
} }
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _tabs = [ static const _baseTabs = [
Routes.briefing, Routes.journal,
Routes.knowledge, Routes.knowledge,
Routes.conversations, Routes.conversations,
Routes.projects,
];
List<String> _tabs() => [
..._baseTabs,
Routes.calendar,
]; ];
// Minimum gap between app-resume refreshes to avoid hammering the server. // Minimum gap between app-resume refreshes to avoid hammering the server.
@@ -202,6 +217,8 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) { 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. // Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider); final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) { if (repoUrl != null && repoUrl.isNotEmpty) {
@@ -210,7 +227,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
ref.read(updateProvider.notifier).check(repoUrl); 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(); _syncTimezone();
}); });
} }
@@ -238,23 +255,22 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
void _refreshAll() { void _refreshAll() {
ref.read(conversationsProvider.notifier).refresh(); ref.read(conversationsProvider.notifier).refresh();
ref.read(calendarProvider.notifier).refresh(); ref.read(calendarProvider.notifier).refresh();
ref.read(newsProvider.notifier).refresh();
ref.read(knowledgeProvider.notifier).refresh(); ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating is safe // journalProvider is an AsyncNotifier; invalidating is safe even if
// even if no conversation is open — it doesn't cause flicker since // the journal screen isn't currently mounted.
// the briefing screen isn't a list view. ref.invalidate(journalProvider);
ref.invalidate(briefingProvider);
} }
/// Refresh only the provider backing the given shell tab index. /// Refresh only the provider backing the given shell tab route.
void _refreshTab(int index) { void _refreshTab(String route) {
switch (index) { if (route == Routes.journal) {
case 0: ref.invalidate(journalProvider);
ref.invalidate(briefingProvider); } else if (route == Routes.knowledge) {
case 1:
ref.read(knowledgeProvider.notifier).refresh(); ref.read(knowledgeProvider.notifier).refresh();
case 2: } else if (route == Routes.conversations) {
ref.read(conversationsProvider.notifier).refresh(); ref.read(conversationsProvider.notifier).refresh();
} else if (route == Routes.calendar) {
ref.read(calendarProvider.notifier).refresh();
} }
} }
@@ -267,14 +283,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
} }
} }
int _tabIndex(String location) { int _tabIndex(String location, List<String> tabs) {
for (var i = 0; i < _tabs.length; i++) { for (var i = 0; i < tabs.length; i++) {
if (location.startsWith(_tabs[i])) return 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; return 0;
} }
@@ -287,7 +298,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.folder_outlined), leading: const Icon(LucideIcons.folder),
title: const Text('Projects'), title: const Text('Projects'),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
@@ -295,15 +306,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.newspaper_outlined), leading: const Icon(LucideIcons.calendar),
title: const Text('News'),
onTap: () {
Navigator.pop(context);
context.push(Routes.news);
},
),
ListTile(
leading: const Icon(Icons.calendar_month_outlined),
title: const Text('Calendar'), title: const Text('Calendar'),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
@@ -316,66 +319,26 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
); );
} }
void _showUpdateDialog(UpdateState update) { void _showUpdateSnackbar(UpdateState update) {
showDialog<void>( ScaffoldMessenger.of(context).showSnackBar(
context: context, SnackBar(
builder: (dialogContext) => Consumer( content: Text('v${update.latestVersion} ready to install'),
builder: (context, ref, _) { duration: const Duration(seconds: 6),
final state = ref.watch(updateProvider); action: SnackBarAction(
final isDownloading = state.status == UpdateStatus.downloading; label: 'Install',
return AlertDialog( onPressed: () => ref.read(updateProvider.notifier).install(),
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,
), ),
), ),
],
],
),
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'),
),
],
); );
}, }
void _showQueueFailureSnackbar(QueueFailure failure) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure.message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 6),
), ),
); );
} }
@@ -384,18 +347,37 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Show update dialog once when a new version is detected. // Show update dialog once when a new version is detected.
ref.listen(updateProvider, (prev, next) { ref.listen(updateProvider, (prev, next) {
if (next.status == UpdateStatus.available && if (next.status == UpdateStatus.readyToInstall &&
prev?.status != UpdateStatus.available) { prev?.status != UpdateStatus.readyToInstall) {
WidgetsBinding.instance 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 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. // Refresh the incoming tab's data when switching between shell tabs.
if (_prevTabIndex != null && _prevTabIndex != index && index < 3) { if (_prevTabIndex != null && _prevTabIndex != index) {
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index)); final route = index < tabs.length ? tabs[index] : tabs[0];
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
} }
_prevTabIndex = index; _prevTabIndex = index;
@@ -407,40 +389,40 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: [ children: [
const OfflineBanner(),
const _QuickCaptureBar(), const _QuickCaptureBar(),
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
NavigationRail( NavigationRail(
selectedIndex: index, selectedIndex: index,
onDestinationSelected: (i) { onDestinationSelected: (i) => context.go(tabs[i]),
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
}
},
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: const [ destinations: const [
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined), icon: Icon(LucideIcons.bookOpen),
selectedIcon: Icon(Icons.wb_sunny), selectedIcon: Icon(LucideIcons.bookOpen),
label: Text('Briefing'), label: Text('Journal'),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined), icon: Icon(LucideIcons.lightbulb),
selectedIcon: Icon(Icons.menu_book), selectedIcon: Icon(LucideIcons.lightbulb),
label: Text('Knowledge'), label: Text('Knowledge'),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline), icon: Icon(LucideIcons.messageCircle),
selectedIcon: Icon(Icons.chat_bubble), selectedIcon: Icon(LucideIcons.messageCircle),
label: Text('Chat'), label: Text('Chat'),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.more_horiz_outlined), icon: Icon(LucideIcons.folder),
selectedIcon: Icon(Icons.more_horiz), selectedIcon: Icon(LucideIcons.folder),
label: Text('More'), label: Text('Projects'),
),
NavigationRailDestination(
icon: Icon(LucideIcons.calendar),
selectedIcon: Icon(LucideIcons.calendar),
label: Text('Calendar'),
), ),
], ],
), ),
@@ -458,6 +440,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
return Scaffold( return Scaffold(
body: Column( body: Column(
children: [ children: [
const OfflineBanner(),
const _QuickCaptureBar(), const _QuickCaptureBar(),
Expanded( Expanded(
child: MediaQuery.removePadding( child: MediaQuery.removePadding(
@@ -469,33 +452,33 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
], ],
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: index, selectedIndex: index >= 3 ? 3 : index,
onDestinationSelected: (i) { onDestinationSelected: (i) {
if (i == 3) { if (i == 3) {
_showMoreSheet(context); _showMoreSheet(context);
} else { } else {
context.go(_tabs[i]); context.go(tabs[i]);
} }
}, },
destinations: const [ destinations: const [
NavigationDestination( NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined), icon: Icon(LucideIcons.bookOpen),
selectedIcon: Icon(Icons.wb_sunny), selectedIcon: Icon(LucideIcons.bookOpen),
label: 'Briefing', label: 'Journal',
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.menu_book_outlined), icon: Icon(LucideIcons.lightbulb),
selectedIcon: Icon(Icons.menu_book), selectedIcon: Icon(LucideIcons.lightbulb),
label: 'Knowledge', label: 'Knowledge',
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.chat_bubble_outline), icon: Icon(LucideIcons.messageCircle),
selectedIcon: Icon(Icons.chat_bubble), selectedIcon: Icon(LucideIcons.messageCircle),
label: 'Chat', label: 'Chat',
), ),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.more_horiz_outlined), icon: Icon(LucideIcons.moreHorizontal),
selectedIcon: Icon(Icons.more_horiz), selectedIcon: Icon(LucideIcons.moreHorizontal),
label: 'More', label: 'More',
), ),
], ],
@@ -523,7 +506,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose(); super.dispose();
} }
@@ -627,7 +609,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
prefixIcon: totalPending > 0 prefixIcon: totalPending > 0
? Badge( ? Badge(
label: Text('$totalPending'), label: Text('$totalPending'),
child: const Icon(Icons.cloud_upload_outlined), child: const Icon(LucideIcons.uploadCloud),
) )
: isWorking : isWorking
? const Padding( ? const Padding(
@@ -639,10 +621,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
strokeWidth: 2), strokeWidth: 2),
), ),
) )
: const Icon(Icons.auto_awesome_outlined), : const Icon(LucideIcons.sparkles),
suffixIcon: _controller.text.trim().isNotEmpty suffixIcon: _controller.text.trim().isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(Icons.send), icon: const Icon(LucideIcons.send),
onPressed: _submit, onPressed: _submit,
tooltip: 'Capture', tooltip: 'Capture',
) )
@@ -657,7 +639,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
onTap: _toggleCaptureMic, onTap: _toggleCaptureMic,
), ),
IconButton( IconButton(
icon: const Icon(Icons.settings_outlined), icon: const Icon(LucideIcons.settings),
tooltip: 'Settings', tooltip: 'Settings',
onPressed: () => context.push(Routes.settings), onPressed: () => context.push(Routes.settings),
), ),
+1 -2
View File
@@ -16,8 +16,7 @@ abstract class Routes {
static const chat = '/chat/:id'; static const chat = '/chat/:id';
static const quickCapture = '/quick-capture'; static const quickCapture = '/quick-capture';
static const settings = '/settings'; static const settings = '/settings';
static const briefing = '/briefing'; static const journal = '/journal';
static const news = '/news';
static const calendar = '/calendar'; static const calendar = '/calendar';
static const projectTasks = '/projects/:id/tasks'; static const projectTasks = '/projects/:id/tasks';
} }
+9
View File
@@ -17,3 +17,12 @@ class AuthException extends AppException {
class NotFoundException extends AppException { class NotFoundException extends AppException {
const NotFoundException(super.message); 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'; import 'package:google_fonts/google_fonts.dart';
// ── Colour constants ────────────────────────────────────────────────────────── // ── 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(0xFF0F0F14); // Dark mode — Obsidian / Iron / Pewter / Parchment (FabledSword baseline)
const _darkSurface = Color(0xFF16161F); const _darkBackground = Color(0xFF14171A); // Obsidian
const _darkSurfaceVar = Color(0xFF1A1A24); const _darkSurface = Color(0xFF1E2228); // Iron
const _darkPrimary = Color(0xFF7C3AED); const _darkSurfaceVar = Color(0xFF2C313A); // Slate
const _darkOnSurface = Color(0xFFE4E4F0); const _darkPrimary = Color(0xFF5B4A8A); // dusty violet (Scribe accent)
const _darkOnSurfaceVar = Color(0xFF8888A8); const _darkPrimaryDeep = Color(0xFF3F3560); // gradient stop
const _darkOutline = Color(0xFF2A2A3A); const _darkOnSurface = Color(0xFFE8E4D8); // Parchment
const _darkOnSurfaceVar = Color(0xFFC2BFB4); // Vellum
const _darkOutline = Color(0xFF3F4651); // Pewter
const _lightBackground = Color(0xFFF5F5FB); // Light mode — warm parchment (Scribe iteration)
const _lightSurface = Color(0xFFFFFFFF); const _lightBackground = Color(0xFFF5F1E8); // warm cream page
const _lightSurfaceVar = Color(0xFFF0F0F8); const _lightSurface = Color(0xFFFBF8F0); // near-white card
const _lightPrimary = Color(0xFF7C3AED); const _lightSurfaceVar = Color(0xFFEFEAE0); // inset / hover surface
const _lightOnSurface = Color(0xFF1A1A1A); const _lightPrimary = Color(0xFF5B4A8A); // dusty violet (same on both modes)
const _lightOnSurfaceVar = Color(0xFF666666); const _lightOnSurface = Color(0xFF14171A); // deep ink (Obsidian inverted)
const _lightOutline = Color(0xFFDDDDE8); 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 ───────────────────────────────────────────────────────────────── // ── 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) { TextTheme _buildTextTheme(TextTheme base) {
final inter = GoogleFonts.interTextTheme(base);
final fraunces = GoogleFonts.frauncesTextTheme(base); final fraunces = GoogleFonts.frauncesTextTheme(base);
return base.copyWith( return inter.copyWith(
// Headings / titles use Fraunces displayLarge: fraunces.displayLarge,
displayMedium: fraunces.displayMedium,
displaySmall: fraunces.displaySmall,
headlineLarge: fraunces.headlineLarge, headlineLarge: fraunces.headlineLarge,
headlineMedium: fraunces.headlineMedium, headlineMedium: fraunces.headlineMedium,
headlineSmall: fraunces.headlineSmall, headlineSmall: fraunces.headlineSmall,
titleLarge: fraunces.titleLarge, titleLarge: fraunces.titleLarge,
titleMedium: fraunces.titleMedium, // titleMedium / titleSmall / body* / label* stay Inter
// Body / labels remain system default
); );
} }
@@ -41,7 +148,7 @@ ThemeData fabledDarkTheme() {
brightness: Brightness.dark, brightness: Brightness.dark,
primary: _darkPrimary, primary: _darkPrimary,
onPrimary: Colors.white, onPrimary: Colors.white,
primaryContainer: const Color(0xFF5B21B6), primaryContainer: _darkPrimaryDeep,
onPrimaryContainer: _darkOnSurface, onPrimaryContainer: _darkOnSurface,
secondary: _darkPrimary, secondary: _darkPrimary,
onSecondary: Colors.white, onSecondary: Colors.white,
@@ -51,10 +158,10 @@ ThemeData fabledDarkTheme() {
onTertiary: Colors.white, onTertiary: Colors.white,
tertiaryContainer: _darkSurfaceVar, tertiaryContainer: _darkSurfaceVar,
onTertiaryContainer: _darkOnSurface, onTertiaryContainer: _darkOnSurface,
error: const Color(0xFFEF4444), error: _semanticError,
onError: Colors.white, onError: Colors.white,
errorContainer: const Color(0xFF7F1D1D), errorContainer: _semanticErrorBg,
onErrorContainer: const Color(0xFFFEE2E2), onErrorContainer: _semanticErrorFg,
surface: _darkSurface, surface: _darkSurface,
onSurface: _darkOnSurface, onSurface: _darkOnSurface,
surfaceContainerHighest: _darkSurfaceVar, surfaceContainerHighest: _darkSurfaceVar,
@@ -73,6 +180,7 @@ ThemeData fabledDarkTheme() {
colorScheme: cs, colorScheme: cs,
scaffoldBackgroundColor: _darkBackground, scaffoldBackgroundColor: _darkBackground,
textTheme: _buildTextTheme(ThemeData.dark().textTheme), textTheme: _buildTextTheme(ThemeData.dark().textTheme),
extensions: const [ActionColors._kStandard],
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: _darkSurface, color: _darkSurface,
elevation: 2, elevation: 2,
@@ -91,15 +199,15 @@ ThemeData fabledDarkTheme() {
filled: true, filled: true,
fillColor: _darkSurfaceVar, fillColor: _darkSurfaceVar,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkOutline), borderSide: BorderSide(color: _darkOutline),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkOutline), borderSide: BorderSide(color: _darkOutline),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkPrimary, width: 2), borderSide: BorderSide(color: _darkPrimary, width: 2),
), ),
), ),
@@ -118,7 +226,9 @@ ThemeData fabledLightTheme() {
brightness: Brightness.light, brightness: Brightness.light,
primary: _lightPrimary, primary: _lightPrimary,
onPrimary: Colors.white, onPrimary: Colors.white,
primaryContainer: const Color(0xFFEDE5FF), // 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, onPrimaryContainer: _lightOnSurface,
secondary: _lightPrimary, secondary: _lightPrimary,
onSecondary: Colors.white, onSecondary: Colors.white,
@@ -128,10 +238,10 @@ ThemeData fabledLightTheme() {
onTertiary: Colors.white, onTertiary: Colors.white,
tertiaryContainer: _lightSurfaceVar, tertiaryContainer: _lightSurfaceVar,
onTertiaryContainer: _lightOnSurface, onTertiaryContainer: _lightOnSurface,
error: const Color(0xFFDC2626), error: _semanticError,
onError: Colors.white, onError: Colors.white,
errorContainer: const Color(0xFFFEE2E2), errorContainer: const Color(0xFFFEE2E2),
onErrorContainer: const Color(0xFF7F1D1D), onErrorContainer: _semanticErrorBg,
surface: _lightSurface, surface: _lightSurface,
onSurface: _lightOnSurface, onSurface: _lightOnSurface,
surfaceContainerHighest: _lightSurfaceVar, surfaceContainerHighest: _lightSurfaceVar,
@@ -150,6 +260,7 @@ ThemeData fabledLightTheme() {
colorScheme: cs, colorScheme: cs,
scaffoldBackgroundColor: _lightBackground, scaffoldBackgroundColor: _lightBackground,
textTheme: _buildTextTheme(ThemeData.light().textTheme), textTheme: _buildTextTheme(ThemeData.light().textTheme),
extensions: const [ActionColors._kStandard],
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: _lightSurface, color: _lightSurface,
elevation: 1, elevation: 1,
@@ -168,15 +279,15 @@ ThemeData fabledLightTheme() {
filled: true, filled: true,
fillColor: _lightSurfaceVar, fillColor: _lightSurfaceVar,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightOutline), borderSide: BorderSide(color: _lightOutline),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightOutline), borderSide: BorderSide(color: _lightOutline),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightPrimary, width: 2), borderSide: BorderSide(color: _lightPrimary, width: 2),
), ),
), ),
@@ -191,7 +302,10 @@ ThemeData fabledLightTheme() {
} }
// ── GradientButton ───────────────────────────────────────────────────────────── // ── 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 { class GradientButton extends StatelessWidget {
final VoidCallback? onPressed; final VoidCallback? onPressed;
@@ -218,15 +332,15 @@ class GradientButton extends StatelessWidget {
: const LinearGradient( : const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)], colors: [_darkPrimary, _darkPrimaryDeep],
), ),
color: disabled ? const Color(0xFF7C3AED) : null, color: disabled ? _darkPrimary : null,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: disabled boxShadow: disabled
? null ? null
: [ : [
BoxShadow( BoxShadow(
color: const Color(0xFF7C3AED).withValues(alpha: 0.45), color: _darkPrimary.withValues(alpha: 0.45),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 3), offset: const Offset(0, 3),
), ),
+16
View File
@@ -63,5 +63,21 @@ AppException dioToApp(DioException e) {
final status = e.response?.statusCode; final status = e.response?.statusCode;
if (status == 401) return const AuthException('Not authenticated.'); if (status == 401) return const AuthException('Not authenticated.');
if (status == 404) return const NotFoundException('Resource not found.'); 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.'); 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;
}
-97
View File
@@ -1,97 +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);
}
}
/// POST /api/briefing/articles/{itemId}/discuss body: {"conv_id": convId}
/// Injects the article as context and triggers LLM generation.
/// Returns the assistant_message_id of the generating placeholder.
Future<int> discussArticle(int convId, int itemId) async {
try {
final response = await _dio.post(
'/api/briefing/articles/$itemId/discuss',
data: {'conv_id': convId},
);
final data = response.data as Map<String, dynamic>;
return data['assistant_message_id'] as int;
} on DioException catch (e) {
throw dioToApp(e);
}
}
/// DELETE /api/briefing/rss-reactions/{rssItemId}
Future<void> deleteRssReaction(int rssItemId) async {
try {
await _dio.delete('/api/briefing/rss-reactions/$rssItemId');
} on DioException catch (e) {
throw dioToApp(e);
}
}
}
-13
View File
@@ -164,17 +164,4 @@ class ChatApi {
} }
} }
/// 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);
}
}
} }
+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);
}
}
}
-52
View File
@@ -1,52 +0,0 @@
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);
}
}
}
+9
View File
@@ -10,4 +10,13 @@ class SettingsApi {
data: {'user_timezone': ianaTimezone}, 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);
}
} }
+3 -3
View File
@@ -40,7 +40,7 @@ class VoiceApi {
} }
} }
/// POST WebM/Opus audio bytes and return the transcript string. /// POST audio bytes (WAV) and return the transcript string.
/// [context] is optional recent conversation text passed as initial_prompt /// [context] is optional recent conversation text passed as initial_prompt
/// to Whisper, reducing mishearings of domain-specific words. /// to Whisper, reducing mishearings of domain-specific words.
/// Returns empty string on empty or error response. /// Returns empty string on empty or error response.
@@ -49,8 +49,8 @@ class VoiceApi {
final fields = <String, dynamic>{ final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes( 'audio': MultipartFile.fromBytes(
audioBytes, audioBytes,
filename: 'audio.m4a', filename: 'audio.wav',
contentType: DioMediaType('audio', 'mp4'), contentType: DioMediaType('audio', 'wav'),
), ),
if (context != null && context.isNotEmpty) 'context': context, if (context != null && context.isNotEmpty) 'context': context,
}; };
+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,
);
}
-20
View File
@@ -1,20 +0,0 @@
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?,
);
}
+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,
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ class KnowledgeItem {
id: json['id'] as int, id: json['id'] as int,
noteType: json['note_type'] as String? ?? 'note', noteType: json['note_type'] as String? ?? 'note',
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
body: json['body'] as String? ?? '', body: (json['snippet'] ?? json['body']) as String? ?? '',
tags: (json['tags'] as List<dynamic>?) tags: (json['tags'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
-37
View File
@@ -1,37 +0,0 @@
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?,
);
}
+28 -3
View File
@@ -1,21 +1,46 @@
import '../../core/exceptions.dart';
import '../api/chat_api.dart'; import '../api/chat_api.dart';
export '../api/chat_api.dart' export '../api/chat_api.dart'
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall; show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
import '../local/database.dart';
import '../models/conversation.dart'; import '../models/conversation.dart';
import '../models/message.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 { class ChatRepository {
final ChatApi _api; 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) => Future<Conversation> createConversation(String title) =>
_api.createConversation(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) => Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
_api.getMessages(conversationId); _api.getMessages(conversationId);
Future<void> sendMessage(int conversationId, String content) => Future<void> sendMessage(int conversationId, String content) =>
_api.sendMessage(conversationId, content); _api.sendMessage(conversationId, content);
Stream<ChatStreamEvent> streamGeneration(int conversationId) => Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
_api.streamGeneration(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;
}
+149 -11
View File
@@ -1,26 +1,164 @@
import '../../core/exceptions.dart';
import '../api/milestones_api.dart'; import '../api/milestones_api.dart';
import '../local/database.dart';
import '../models/milestone.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 { class MilestonesRepository {
final MilestonesApi _api; final MilestonesApi _api;
const MilestonesRepository(this._api); final FabledDatabase _db;
Future<List<Milestone>> getAll(int projectId, {String? status}) => const MilestonesRepository(this._api, this._db);
_api.getAll(projectId, status: status);
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( Future<Milestone> create(
int projectId, { int projectId, {
required String title, required String title,
String? description, String? description,
int orderIndex = 0, int orderIndex = 0,
}) => }) async {
_api.create(projectId, try {
title: title, description: description, orderIndex: orderIndex); 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( Future<Milestone> update(
int projectId, int milestoneId, Map<String, dynamic> fields) => int projectId,
_api.update(projectId, milestoneId, fields); int milestoneId,
Map<String, dynamic> fields,
Future<void> delete(int projectId, int milestoneId) => ) async {
_api.delete(projectId, milestoneId); 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) 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,
);
} }
+153 -11
View File
@@ -1,12 +1,53 @@
import '../../core/exceptions.dart';
import '../api/notes_api.dart'; import '../api/notes_api.dart';
import '../local/database.dart';
import '../models/note.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 { class NotesRepository {
final NotesApi _api; final NotesApi _api;
const NotesRepository(this._api); final FabledDatabase _db;
Future<List<Note>> getAll() => _api.getAll(); const NotesRepository(this._api, this._db);
Future<Note> getOne(int id) => _api.getOne(id);
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( Future<Note> create(
String title, String title,
@@ -14,9 +55,46 @@ class NotesRepository {
List<String> tags = const [], List<String> tags = const [],
int? projectId, int? projectId,
String noteType = 'note', String noteType = 'note',
}) => }) async {
_api.create(title, body, try {
tags: tags, projectId: projectId, noteType: noteType); 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( Future<Note> update(
int id, int id,
@@ -26,12 +104,76 @@ class NotesRepository {
int? projectId, int? projectId,
bool clearProject = false, bool clearProject = false,
String noteType = 'note', String noteType = 'note',
}) => }) async {
_api.update(id, title, body, try {
final updated = await _api.update(
id,
title,
body,
tags: tags, tags: tags,
projectId: projectId, projectId: projectId,
clearProject: clearProject, clearProject: clearProject,
noteType: noteType); noteType: noteType,
);
Future<void> delete(int id) => _api.delete(id); 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) 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,
);
}
}
} }
+147 -10
View File
@@ -1,31 +1,168 @@
import '../../core/exceptions.dart';
import '../api/projects_api.dart'; import '../api/projects_api.dart';
import '../local/database.dart';
import '../models/project.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 { class ProjectsRepository {
final ProjectsApi _api; final ProjectsApi _api;
const ProjectsRepository(this._api); final FabledDatabase _db;
const ProjectsRepository(this._api, this._db);
Future<List<Project>> getAll({ Future<List<Project>> getAll({
String? status, String? status,
String sort = 'updated_at', String sort = 'updated_at',
String order = 'desc', String order = 'desc',
}) => }) async {
_api.getAll(status: status, sort: sort, order: order); try {
Future<Project> getOne(int id) => _api.getOne(id); 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<Project> create({ Future<Project> create({
required String title, required String title,
String? description, String? description,
String? goal, String? goal,
String? color, String? color,
String status = 'active', String status = 'active',
}) => }) async {
_api.create( try {
final project = await _api.create(
title: title, title: title,
description: description, description: description,
goal: goal, goal: goal,
color: color, color: color,
status: status); status: status,
Future<Project> update(int id, Map<String, dynamic> fields) => );
_api.update(id, fields); await _db.upsertProject(project);
Future<void> delete(int id) => _api.delete(id); 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,
);
} }
+172 -13
View File
@@ -1,12 +1,63 @@
import '../../core/exceptions.dart';
import '../api/tasks_api.dart'; import '../api/tasks_api.dart';
import '../local/database.dart';
import '../models/task.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 { class TasksRepository {
final TasksApi _api; final TasksApi _api;
const TasksRepository(this._api); final FabledDatabase _db;
Future<List<Task>> getAll() => _api.getAll(); const TasksRepository(this._api, this._db);
Future<Task> getOne(int id) => _api.getOne(id);
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({ Future<Task> create({
required String title, required String title,
@@ -16,8 +67,9 @@ class TasksRepository {
DateTime? dueDate, DateTime? dueDate,
int? projectId, int? projectId,
int? parentId, int? parentId,
}) => }) async {
_api.create( try {
final task = await _api.create(
title: title, title: title,
description: description, description: description,
status: status, status: status,
@@ -26,12 +78,119 @@ class TasksRepository {
projectId: projectId, projectId: projectId,
parentId: parentId, parentId: parentId,
); );
await _db.upsertTask(task);
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId); return task;
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId); } on NetworkException {
final tempId = await _db.nextTempId();
Future<Task> update(int id, Map<String, dynamic> fields) => final now = DateTime.now();
_api.update(id, fields); final optimistic = Task(
id: tempId,
Future<void> delete(int id) => _api.delete(id); 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<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<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,
);
} }
+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);
}
+83 -13
View File
@@ -4,26 +4,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/api_client.dart'; import '../data/api/api_client.dart';
import '../data/api/auth_api.dart'; import '../data/api/auth_api.dart';
import '../data/api/briefing_api.dart';
import '../data/api/chat_api.dart'; import '../data/api/chat_api.dart';
import '../data/api/journal_api.dart';
import '../data/api/knowledge_api.dart'; import '../data/api/knowledge_api.dart';
import '../data/api/voice_api.dart'; import '../data/api/voice_api.dart';
import '../data/api/milestones_api.dart'; import '../data/api/milestones_api.dart';
import '../data/api/events_api.dart'; import '../data/api/events_api.dart';
import '../data/api/news_api.dart';
import '../data/api/notes_api.dart'; import '../data/api/notes_api.dart';
import '../data/api/projects_api.dart'; import '../data/api/projects_api.dart';
import '../data/api/quick_capture_api.dart'; import '../data/api/quick_capture_api.dart';
import '../data/api/settings_api.dart'; import '../data/api/settings_api.dart';
import '../data/api/tasks_api.dart'; import '../data/api/tasks_api.dart';
import '../data/local/database.dart';
import '../data/repositories/auth_repository.dart'; import '../data/repositories/auth_repository.dart';
import '../data/repositories/chat_repository.dart'; import '../data/repositories/chat_repository.dart';
import '../data/repositories/events_repository.dart';
import '../data/repositories/knowledge_repository.dart'; import '../data/repositories/knowledge_repository.dart';
import '../data/repositories/voice_repository.dart'; import '../data/repositories/voice_repository.dart';
import '../data/repositories/milestones_repository.dart'; import '../data/repositories/milestones_repository.dart';
import '../data/repositories/notes_repository.dart'; import '../data/repositories/notes_repository.dart';
import '../data/repositories/projects_repository.dart'; import '../data/repositories/projects_repository.dart';
import '../data/repositories/tasks_repository.dart'; import '../data/repositories/tasks_repository.dart';
import '../data/repositories/write_queue.dart';
import 'settings_provider.dart'; import 'settings_provider.dart';
final cookieJarProvider = Provider<PersistCookieJar>((ref) { final cookieJarProvider = Provider<PersistCookieJar>((ref) {
@@ -61,24 +63,45 @@ final projectsApiProvider = Provider<ProjectsApi>((ref) {
return ProjectsApi(ref.watch(dioProvider)); 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) { final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(ref.watch(authApiProvider)); return AuthRepository(ref.watch(authApiProvider));
}); });
final notesRepositoryProvider = Provider<NotesRepository>((ref) { final notesRepositoryProvider = Provider<NotesRepository>((ref) {
return NotesRepository(ref.watch(notesApiProvider)); return NotesRepository(
ref.watch(notesApiProvider),
ref.watch(fabledDatabaseProvider),
);
}); });
final tasksRepositoryProvider = Provider<TasksRepository>((ref) { final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
return TasksRepository(ref.watch(tasksApiProvider)); return TasksRepository(
ref.watch(tasksApiProvider),
ref.watch(fabledDatabaseProvider),
);
}); });
final chatRepositoryProvider = Provider<ChatRepository>((ref) { final chatRepositoryProvider = Provider<ChatRepository>((ref) {
return ChatRepository(ref.watch(chatApiProvider)); return ChatRepository(
ref.watch(chatApiProvider),
ref.watch(fabledDatabaseProvider),
);
}); });
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) { final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
return ProjectsRepository(ref.watch(projectsApiProvider)); return ProjectsRepository(
ref.watch(projectsApiProvider),
ref.watch(fabledDatabaseProvider),
);
}); });
final milestonesApiProvider = Provider<MilestonesApi>((ref) { final milestonesApiProvider = Provider<MilestonesApi>((ref) {
@@ -86,7 +109,10 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
}); });
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) { final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
return MilestonesRepository(ref.watch(milestonesApiProvider)); return MilestonesRepository(
ref.watch(milestonesApiProvider),
ref.watch(fabledDatabaseProvider),
);
}); });
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) { final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
@@ -97,8 +123,8 @@ final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
return KnowledgeRepository(ref.watch(knowledgeApiProvider)); return KnowledgeRepository(ref.watch(knowledgeApiProvider));
}); });
final briefingApiProvider = Provider<BriefingApi>((ref) { final journalApiProvider = Provider<JournalApi>((ref) {
return BriefingApi(ref.watch(dioProvider)); return JournalApi(ref.watch(dioProvider));
}); });
final settingsApiProvider = Provider<SettingsApi>((ref) { final settingsApiProvider = Provider<SettingsApi>((ref) {
@@ -113,10 +139,54 @@ final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
return VoiceRepository(ref.watch(voiceApiProvider)); return VoiceRepository(ref.watch(voiceApiProvider));
}); });
final newsApiProvider = Provider<NewsApi>((ref) {
return NewsApi(ref.watch(dioProvider));
});
final eventsApiProvider = Provider<EventsApi>((ref) { final eventsApiProvider = Provider<EventsApi>((ref) {
return EventsApi(ref.watch(dioProvider)); 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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart';
import 'api_client_provider.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); final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
@@ -14,7 +16,12 @@ class AuthNotifier extends Notifier<AuthStatus> {
try { try {
final repo = ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
final ok = await repo.verify(); final ok = await repo.verify();
if (ok) {
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
}
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated; state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
} on NetworkException {
state = AuthStatus.offline;
} catch (_) { } catch (_) {
state = AuthStatus.unauthenticated; state = AuthStatus.unauthenticated;
} }
@@ -23,6 +30,7 @@ class AuthNotifier extends Notifier<AuthStatus> {
Future<void> login(String username, String password) async { Future<void> login(String username, String password) async {
final repo = ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
await repo.login(username, password); await repo.login(username, password);
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
state = AuthStatus.authenticated; state = AuthStatus.authenticated;
} }
+3 -3
View File
@@ -47,7 +47,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
// Fetch current month ± 1 month as the initial window. // Fetch current month ± 1 month as the initial window.
final from = DateTime(now.year, now.month - 1, 1); final from = DateTime(now.year, now.month - 1, 1);
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59); final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
final events = await ref.watch(eventsApiProvider).getEvents(from, to); final events = await ref.watch(eventsRepositoryProvider).getEvents(from, to);
return CalendarState( return CalendarState(
eventsByDay: _groupByDay(events), eventsByDay: _groupByDay(events),
selectedDay: today, selectedDay: today,
@@ -60,7 +60,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
Future<void> refresh() async { Future<void> refresh() async {
final current = state.value; final current = state.value;
if (current == null) return; if (current == null) return;
final events = await ref.read(eventsApiProvider).getEvents( final events = await ref.read(eventsRepositoryProvider).getEvents(
current.loadedRange.start, current.loadedRange.start,
current.loadedRange.end, current.loadedRange.end,
); );
@@ -93,7 +93,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
try { try {
final from = DateTime(month.year, month.month, 1); final from = DateTime(month.year, month.month, 1);
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59); final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
final events = await ref.read(eventsApiProvider).getEvents(from, to); final events = await ref.read(eventsRepositoryProvider).getEvents(from, to);
final s = state.value!; final s = state.value!;
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay); final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
for (final e in events) { for (final e in events) {
@@ -3,12 +3,12 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/chat_api.dart'; import '../data/api/chat_api.dart';
import '../data/models/briefing_conversation.dart'; import '../data/models/journal_day.dart';
import '../data/models/message.dart'; import '../data/models/message.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
/// Drives the loading indicator in BriefingScreen's reply area. /// Drives the loading indicator in JournalScreen's reply area.
final isBriefingStreamingProvider = final isJournalStreamingProvider =
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new); NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
class _BoolNotifier extends Notifier<bool> { class _BoolNotifier extends Notifier<bool> {
@@ -16,23 +16,22 @@ class _BoolNotifier extends Notifier<bool> {
bool build() => false; bool build() => false;
} }
final briefingProvider = final journalProvider =
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>( AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
BriefingNotifier.new);
class BriefingNotifier extends AsyncNotifier<BriefingConversation> { class JournalNotifier extends AsyncNotifier<JournalDay> {
@override @override
Future<BriefingConversation> build() async { Future<JournalDay> build() async {
return ref.read(briefingApiProvider).getToday(); return ref.read(journalApiProvider).getToday();
} }
/// Silently fetch the latest briefing and patch state without triggering /// Silently fetch today's journal and patch state without triggering
/// AsyncLoading existing content stays visible while the fetch is in flight. /// AsyncLoading existing content stays visible while the fetch is in flight.
Future<void> silentRefresh() async { Future<void> silentRefresh() async {
final current = state.value; final current = state.value;
if (current == null) return; if (current == null) return;
try { try {
final fresh = await ref.read(briefingApiProvider).getToday(); final fresh = await ref.read(journalApiProvider).getToday();
final curLast = current.messages.isNotEmpty ? current.messages.last : null; final curLast = current.messages.isNotEmpty ? current.messages.last : null;
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null; final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
if (fresh.messages.length != current.messages.length || if (fresh.messages.length != current.messages.length ||
@@ -40,26 +39,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
state = AsyncData(fresh); state = AsyncData(fresh);
} }
} catch (_) { } catch (_) {
// Network hiccup silently ignore, keep existing content // Network hiccup silently ignore, keep existing content.
} }
} }
/// Trigger a briefing slot (e.g. "compilation") then reload. /// Force-regenerate today's daily prep then reload.
Future<void> refresh(String slot) async { Future<void> regeneratePrep() async {
await ref.read(briefingApiProvider).triggerSlot(slot); await ref.read(journalApiProvider).triggerPrep();
ref.invalidateSelf(); ref.invalidateSelf();
await future; await future;
} }
/// Re-fetch the current briefing conversation and unfreeze a stuck /// Re-fetch today's journal and unfreeze a stuck streaming state if the
/// streaming state if the server-side message is already complete. /// server-side message is already complete.
/// ///
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE /// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
/// socket dies silently (mobile network handoff, app backgrounded mid-stream, /// socket dies silently the send loop never observes close and
/// reverse proxy dropping idle sockets) the send loop never observes close /// [isJournalStreamingProvider] stays stuck true. This is the manual
/// and [isBriefingStreamingProvider] stays stuck true. This is the manual /// recovery path hit by pull-to-refresh, the AppBar refresh button, and
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and the /// the lifecycle-resume hook.
/// lifecycle-resume hook.
Future<void> refreshMessages() async { Future<void> refreshMessages() async {
final current = state.value; final current = state.value;
if (current == null) { if (current == null) {
@@ -67,7 +65,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
return; return;
} }
try { try {
final fresh = await ref.read(briefingApiProvider).getToday(); final fresh = await ref.read(journalApiProvider).getToday();
state = AsyncData(fresh); state = AsyncData(fresh);
final messages = fresh.messages; final messages = fresh.messages;
Message? lastAssistant; Message? lastAssistant;
@@ -78,46 +76,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
} }
} }
if (lastAssistant != null && lastAssistant.status != 'generating') { if (lastAssistant != null && lastAssistant.status != 'generating') {
ref.read(isBriefingStreamingProvider.notifier).state = false; ref.read(isJournalStreamingProvider.notifier).state = false;
} }
} catch (_) { } catch (_) {
// Network hiccup keep existing state; user can retry. // Network hiccup keep existing state; user can retry.
} }
} }
/// Inject a news article as context and trigger generation. /// Send a reply to today's journal conversation.
///
/// Mirrors sendReply() but calls the /discuss endpoint instead of
/// /messages so the backend injects article content before generating.
Future<void> discussArticle(int convId, int itemId) async {
final conv = state.value;
if (conv == null) return;
final chatApi = ref.read(chatApiProvider);
final briefingApi = ref.read(briefingApiProvider);
final previous = conv.messages;
final placeholder = Message(
conversationId: convId,
role: MessageRole.assistant,
content: '',
status: 'generating',
);
state = AsyncData(conv.copyWith(messages: [...previous, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true;
try {
await briefingApi.discussArticle(convId, itemId);
} catch (e) {
state = AsyncData(conv.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false;
rethrow;
}
final streamedContent = await _consumeStream(chatApi.streamGeneration(convId));
await _pollUntilComplete(convId, streamedContent);
}
/// Send a reply to today's briefing conversation.
/// ///
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same /// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
/// stall-watchdog pattern: /// stall-watchdog pattern:
@@ -126,12 +92,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
/// 3. SSE stream with per-event timeout (stall watchdog) /// 3. SSE stream with per-event timeout (stall watchdog)
/// 4. Poll until complete /// 4. Poll until complete
Future<void> sendReply(String content) async { Future<void> sendReply(String content) async {
final conv = state.value; final day = state.value;
if (day == null) return;
final conv = day.conversation;
if (conv == null) return; if (conv == null) return;
final convId = conv.id; final convId = conv.id;
final chatApi = ref.read(chatApiProvider); final chatApi = ref.read(chatApiProvider);
final previous = conv.messages; final previous = day.messages;
final userMsg = Message( final userMsg = Message(
conversationId: convId, conversationId: convId,
role: MessageRole.user, role: MessageRole.user,
@@ -143,14 +111,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
content: '', content: '',
status: 'generating', status: 'generating',
); );
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder])); state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true; ref.read(isJournalStreamingProvider.notifier).state = true;
try { try {
await chatApi.sendMessage(convId, content); await chatApi.sendMessage(convId, content);
} catch (e) { } catch (e) {
state = AsyncData(conv.copyWith(messages: previous)); state = AsyncData(day.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false; ref.read(isJournalStreamingProvider.notifier).state = false;
rethrow; rethrow;
} }
@@ -158,19 +126,15 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
await _pollUntilComplete(convId, streamedContent); await _pollUntilComplete(convId, streamedContent);
} }
/// Consume an SSE stream into the current briefing conversation state. /// Consume an SSE stream into the current journal day state.
/// ///
/// Uses a StreamIterator with a per-event timeout as a stall watchdog /// Uses a StreamIterator with a per-event timeout as a stall watchdog
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart. /// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
/// Mobile networks occasionally drop SSE sockets silently: the TCP /// Mobile networks occasionally drop SSE sockets silently. If no event
/// connection is half-closed, Dio never sees the close, and `await for` /// arrives within the watchdog window we bail out and let polling
/// hangs forever with [isBriefingStreamingProvider] stuck true. If no event /// reconcile state from the server.
/// arrives within the watchdog window we bail out and let the polling pass
/// below reconcile state from the server.
/// ///
/// Returns whether any text content was actually streamed (the polling /// Returns whether any text content was actually streamed.
/// pass uses this to decide whether it's safe to overwrite with a possibly
/// empty server-side row).
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async { Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
const stallTimeout = Duration(seconds: 45); const stallTimeout = Duration(seconds: 45);
bool streamedContent = false; bool streamedContent = false;
@@ -208,24 +172,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
return streamedContent; return streamedContent;
} }
/// Poll /api/briefing messages until the last assistant row is complete, /// Poll today's journal until the last assistant row is complete. Always
/// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears /// clears [isJournalStreamingProvider] at the end so the input can't stay
/// [isBriefingStreamingProvider] at the end so the input can't stay locked. /// locked.
Future<void> _pollUntilComplete(int convId, bool streamedContent) async { Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
final briefingApi = ref.read(briefingApiProvider); final journalApi = ref.read(journalApiProvider);
try { try {
for (var attempt = 0; attempt < 20; attempt++) { for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await briefingApi.getMessages(convId); final fresh = await journalApi.getToday();
final done = fresh.any( final freshMsgs = fresh.messages;
final done = freshMsgs.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating', (m) => m.role == MessageRole.assistant && m.status != 'generating',
); );
final hasContent = fresh.any( final hasContent = freshMsgs.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty, (m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
); );
final current = state.value; final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) { if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh)); state = AsyncData(current.copyWith(messages: freshMsgs));
} }
if (done) break; if (done) break;
} }
@@ -241,7 +206,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
} }
} }
} finally { } finally {
ref.read(isBriefingStreamingProvider.notifier).state = false; ref.read(isJournalStreamingProvider.notifier).state = false;
} }
} }
} }
-177
View File
@@ -1,177 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/briefing_feed.dart';
import '../data/models/news_item.dart';
import 'api_client_provider.dart';
// ─── NewsState ────────────────────────────────────────────────────────────────
class NewsState {
final List<NewsItem> items;
final int offset;
final bool hasMore;
final bool loadingMore;
final int? selectedFeedId;
final Map<int, String?> reactions;
const NewsState({
required this.items,
required this.offset,
required this.hasMore,
required this.loadingMore,
required this.selectedFeedId,
required this.reactions,
});
NewsState copyWith({
List<NewsItem>? items,
int? offset,
bool? hasMore,
bool? loadingMore,
Object? selectedFeedId = _sentinel,
Map<int, String?>? reactions,
}) {
return NewsState(
items: items ?? this.items,
offset: offset ?? this.offset,
hasMore: hasMore ?? this.hasMore,
loadingMore: loadingMore ?? this.loadingMore,
selectedFeedId: selectedFeedId == _sentinel
? this.selectedFeedId
: selectedFeedId as int?,
reactions: reactions ?? this.reactions,
);
}
}
const _sentinel = Object();
// ─── NewsNotifier ─────────────────────────────────────────────────────────────
final newsProvider =
AsyncNotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
class NewsNotifier extends AsyncNotifier<NewsState> {
static const _limit = 40;
@override
Future<NewsState> build() async {
final items = await ref.watch(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
);
return NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: null,
reactions: {for (final item in items) item.id: item.reaction},
);
}
/// Re-fetch the first page without clearing state (no flicker).
Future<void> refresh() async {
final current = state.value;
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
feedId: current?.selectedFeedId,
);
state = AsyncData(NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: current?.selectedFeedId,
reactions: {for (final item in items) item.id: item.reaction},
));
}
Future<void> loadMore() async {
final current = state.value;
if (current == null || current.loadingMore || !current.hasMore) return;
state = AsyncData(current.copyWith(loadingMore: true));
try {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: current.offset,
feedId: current.selectedFeedId,
);
final updatedReactions = Map<int, String?>.from(current.reactions);
for (final item in items) {
updatedReactions.putIfAbsent(item.id, () => item.reaction);
}
state = AsyncData(current.copyWith(
items: [...current.items, ...items],
offset: current.offset + items.length,
hasMore: items.length == _limit,
loadingMore: false,
reactions: updatedReactions,
));
} catch (e) {
final recovered = state.value ?? current;
state = AsyncData(recovered.copyWith(loadingMore: false));
rethrow;
}
}
Future<void> setFeed(int? feedId) async {
state = const AsyncLoading();
try {
final items = await ref.read(newsApiProvider).getNewsItems(
days: 90,
limit: _limit,
offset: 0,
feedId: feedId,
);
state = AsyncData(NewsState(
items: items,
offset: items.length,
hasMore: items.length == _limit,
loadingMore: false,
selectedFeedId: feedId,
reactions: {for (final item in items) item.id: item.reaction},
));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void toggleReaction(int itemId, String reaction) {
final current = state.value;
if (current == null) return;
final prev = current.reactions[itemId];
final next = prev == reaction ? null : reaction;
state = AsyncData(current.copyWith(
reactions: {...current.reactions, itemId: next},
));
final briefingApi = ref.read(briefingApiProvider);
final future = next == null
? briefingApi.deleteRssReaction(itemId)
: briefingApi.postRssReaction(itemId, next);
future.catchError((_) {
final s = state.value;
if (s != null) {
state = AsyncData(s.copyWith(
reactions: {...s.reactions, itemId: prev},
));
}
});
}
}
// ─── FeedsNotifier ────────────────────────────────────────────────────────────
final feedsProvider =
AsyncNotifierProvider<FeedsNotifier, List<BriefingFeed>>(FeedsNotifier.new);
class FeedsNotifier extends AsyncNotifier<List<BriefingFeed>> {
@override
Future<List<BriefingFeed>> build() async {
return ref.watch(newsApiProvider).getFeeds();
}
}
+47
View File
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'api_client_provider.dart';
const _kServerUrl = 'server_url'; const _kServerUrl = 'server_url';
const _kThemeMode = 'theme_mode'; const _kThemeMode = 'theme_mode';
const _kForgejoRepoUrl = 'forgejo_repo_url'; const _kForgejoRepoUrl = 'forgejo_repo_url';
const _kHasEverLoggedIn = 'has_ever_logged_in';
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) { final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Override in ProviderScope'); throw UnimplementedError('Override in ProviderScope');
@@ -82,3 +85,47 @@ class ServerUrlNotifier extends Notifier<String?> {
state = null; 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());
}
}
+85 -31
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_file/open_file.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:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.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 { class UpdateState {
final UpdateStatus status; final UpdateStatus status;
@@ -14,6 +23,7 @@ class UpdateState {
final String? downloadUrl; final String? downloadUrl;
final double downloadProgress; final double downloadProgress;
final String? errorMessage; final String? errorMessage;
final String? apkPath;
const UpdateState({ const UpdateState({
this.status = UpdateStatus.idle, this.status = UpdateStatus.idle,
@@ -22,6 +32,7 @@ class UpdateState {
this.downloadUrl, this.downloadUrl,
this.downloadProgress = 0.0, this.downloadProgress = 0.0,
this.errorMessage, this.errorMessage,
this.apkPath,
}); });
UpdateState copyWith({ UpdateState copyWith({
@@ -31,6 +42,7 @@ class UpdateState {
String? downloadUrl, String? downloadUrl,
double? downloadProgress, double? downloadProgress,
String? errorMessage, String? errorMessage,
String? apkPath,
}) => }) =>
UpdateState( UpdateState(
status: status ?? this.status, status: status ?? this.status,
@@ -39,6 +51,7 @@ class UpdateState {
downloadUrl: downloadUrl ?? this.downloadUrl, downloadUrl: downloadUrl ?? this.downloadUrl,
downloadProgress: downloadProgress ?? this.downloadProgress, downloadProgress: downloadProgress ?? this.downloadProgress,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
apkPath: apkPath ?? this.apkPath,
); );
} }
@@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier<UpdateState> {
@override @override
UpdateState build() => const UpdateState(); 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 { Future<void> check(String repoUrl) async {
state = state.copyWith(status: UpdateStatus.checking); state = state.copyWith(status: UpdateStatus.checking);
try { try {
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
final currentVersion = final currentVersion =
'${packageInfo.version}.${packageInfo.buildNumber}'; '${packageInfo.version}.${packageInfo.buildNumber}';
// Parse repo URL → Forgejo API endpoint
final uri = Uri.parse(repoUrl); final uri = Uri.parse(repoUrl);
final parts = final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList();
uri.pathSegments.where((s) => s.isNotEmpty).toList();
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)'; if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
final apiUrl = final apiUrl =
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest'; '${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; tagName.startsWith('v') ? tagName.substring(1) : tagName;
if (_isNewer(latestVersion, currentVersion)) { if (_isNewer(latestVersion, currentVersion)) {
final assets = final assets = (response.data['assets'] as List<dynamic>? ?? [])
(response.data['assets'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>(); .cast<Map<String, dynamic>>();
final apk = assets.firstWhere( final apk = assets.firstWhere(
(a) => (a['name'] as String? ?? '').endsWith('.apk'), (a) => (a['name'] as String? ?? '').endsWith('.apk'),
orElse: () => {}, orElse: () => {},
); );
if (apk.isNotEmpty) { if (apk.isNotEmpty) {
final downloadUrl = apk['browser_download_url'] as String?;
state = state.copyWith( state = state.copyWith(
status: UpdateStatus.available,
currentVersion: currentVersion, currentVersion: currentVersion,
latestVersion: latestVersion, latestVersion: latestVersion,
downloadUrl: apk['browser_download_url'] as String?, downloadUrl: downloadUrl,
); );
await _downloadInBackground();
return; return;
} }
} }
@@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier<UpdateState> {
} }
} }
Future<void> downloadAndInstall() async { Future<void> _downloadInBackground() async {
if (state.downloadUrl == null) return; 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; final installPermission = await Permission.requestInstallPackages.status;
if (!installPermission.isGranted) { if (!installPermission.isGranted) {
final result = await Permission.requestInstallPackages.request(); final result = await Permission.requestInstallPackages.request();
@@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier<UpdateState> {
} }
} }
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try { 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( final result = await OpenFile.open(
path, path,
type: 'application/vnd.android.package-archive', type: 'application/vnd.android.package-archive',
); );
if (result.type == ResultType.done) { if (result.type == ResultType.done) {
// Installer launched — reset to idle so the dialog closes naturally.
state = const UpdateState(); state = const UpdateState();
} else { } else {
state = state.copyWith( 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(); 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) { bool _isNewer(String latest, String current) {
try { try {
final l = latest.split('.').map(int.parse).toList(); final l = latest.split('.').map(int.parse).toList();
+179 -142
View File
@@ -1,13 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart'; import 'package:vad/vad.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
@@ -51,6 +52,53 @@ String stripMarkdownForTts(String text) {
.trim(); .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 ───────────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────────
enum VoiceMode { idle, recording, transcribing, playing } enum VoiceMode { idle, recording, transcribing, playing }
@@ -59,9 +107,7 @@ class VoiceState {
final VoiceMode mode; final VoiceMode mode;
final bool voiceModeActive; final bool voiceModeActive;
final bool available; final bool available;
/// Normalized mic amplitude 0.01.0 while recording. Drives the live /// Normalized mic amplitude 0.01.0 while recording.
/// pulse on VoiceMicButton so the user has obvious feedback that audio
/// is actually being picked up.
final double amplitude; final double amplitude;
const VoiceState({ const VoiceState({
@@ -88,35 +134,25 @@ class VoiceState {
// ── Provider ────────────────────────────────────────────────────────────────── // ── Provider ──────────────────────────────────────────────────────────────────
final voiceProvider = final voiceProvider =
NotifierProvider<VoiceNotifier, VoiceState>(VoiceNotifier.new); NotifierProvider.autoDispose<VoiceNotifier, VoiceState>(VoiceNotifier.new);
// ── Notifier ────────────────────────────────────────────────────────────────── // ── Notifier ──────────────────────────────────────────────────────────────────
class VoiceNotifier extends Notifier<VoiceState> { class VoiceNotifier extends Notifier<VoiceState> {
// Audio I/O // Audio playback
AudioRecorder? _recorder;
AudioPlayer? _player; AudioPlayer? _player;
StreamSubscription<Amplitude>? _amplitudeSubscription;
// Recording / silence detection // VAD — sole owner of the microphone
// VadHandler? _vadHandler;
// Silence threshold is dynamic: we track the session peak dBFS and treat StreamSubscription<void>? _vadSpeechStartSub;
// "silent" as "current level is at least _dropFromPeakDb below peak." StreamSubscription<List<double>>? _vadSpeechEndSub;
// This auto-calibrates to whatever mic + room the user is on rather than StreamSubscription<({double isSpeech, double notSpeech, List<double> frame})>?
// assuming a fixed ambient level. Until the peak climbs above _vadFrameSub;
// _dynamicArmDb we fall back to a conservative static threshold so a StreamSubscription<String>? _vadErrorSub;
// quiet room doesn't spin forever. A grace period at the start of the bool _speechDetected = false;
// recording gives the user time to begin speaking before silence checks int _speechStartMs = 0;
// arm. static const _vadGraceMs = 1500;
int _recordingStartMs = 0; bool _disposed = false;
int _silenceMs = 0;
double _peakDb = -100.0;
static const _fallbackThresholdDb = -35.0;
static const _dropFromPeakDb = 15.0;
static const _dynamicArmDb = -20.0;
static const _graceMs = 1500;
static const _silenceDurationMs = 2000;
static const _minRecordingMs = 300;
// Voice mode callbacks // Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript; Future<void> Function(String transcript)? _onTranscript;
@@ -128,11 +164,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
int _lastSeenLength = 0; int _lastSeenLength = 0;
bool _streamComplete = false; bool _streamComplete = false;
// Last complete assistant response — passed to Whisper as initial_prompt // Whisper context hint
// to reduce STT mishearings of domain-specific words.
String _lastAssistantContent = ''; String _lastAssistantContent = '';
// Empty transcript counter — show feedback after consecutive blanks // Empty transcript counter
int _emptyTranscriptCount = 0; int _emptyTranscriptCount = 0;
// TTS playback queue // TTS playback queue
@@ -143,52 +178,51 @@ class VoiceNotifier extends Notifier<VoiceState> {
@override @override
VoiceState build() { VoiceState build() {
_disposed = false;
_player = AudioPlayer(); _player = AudioPlayer();
ref.onDispose(() { ref.onDispose(() {
_amplitudeSubscription?.cancel(); _disposed = true;
_recorder?.dispose(); _cancelSubscriptions();
_vadHandler?.dispose();
_vadHandler = null;
_player?.dispose(); _player?.dispose();
_player = null;
}); });
return const VoiceState(); return const VoiceState();
} }
// ── Public API ────────────────────────────────────────────────────────────── // ── Public API ──────────────────────────────────────────────────────────────
/// Enter voice mode. Checks server availability and mic permission first.
/// [onTranscript] is called with the transcript when a recording completes.
/// [enableTts] — if true, TTS plays when [feedContent] is called.
/// [onError] — called with a human-readable message on failure.
Future<void> enterVoiceMode({ Future<void> enterVoiceMode({
required Future<void> Function(String transcript) onTranscript, required Future<void> Function(String transcript) onTranscript,
bool enableTts = false, bool enableTts = false,
required void Function(String message) onError, required void Function(String message) onError,
}) async { }) async {
if (state.voiceModeActive) { if (state.voiceModeActive) {
if (state.mode == VoiceMode.recording && !_speechDetected) {
onError('No speech detected');
}
exitVoiceMode(); exitVoiceMode();
return; return;
} }
// Check server availability — STT is required, TTS is optional.
try { try {
final status = await ref.read(voiceRepositoryProvider).checkStatus(); final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.enabled || !status.stt) { if (!status.enabled || !status.stt) {
onError('Speech-to-text not available on this server'); onError('Speech-to-text not available on this server');
return; return;
} }
// Downgrade to STT-only when TTS is unavailable
if (!status.tts) enableTts = false; if (!status.tts) enableTts = false;
} catch (_) { } catch (_) {
onError('Could not reach voice service'); onError('Could not reach voice service');
return; return;
} }
// Check microphone permission
var permStatus = await Permission.microphone.request(); var permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.permanentlyDenied) { if (permStatus == PermissionStatus.permanentlyDenied) {
onError('Microphone blocked — opening settings'); onError('Microphone blocked — opening settings');
final opened = await openAppSettings(); final opened = await openAppSettings();
if (!opened) return; if (!opened) return;
// Re-check after user returns from settings
permStatus = await Permission.microphone.status; permStatus = await Permission.microphone.status;
} }
if (!permStatus.isGranted) { if (!permStatus.isGranted) {
@@ -206,25 +240,11 @@ class VoiceNotifier extends Notifier<VoiceState> {
await _startListening(); await _startListening();
} }
/// Exit voice mode, stop all recording and TTS.
void exitVoiceMode() { void exitVoiceMode() {
_amplitudeSubscription?.cancel(); _cleanup();
_amplitudeSubscription = null; if (!_disposed) state = const VoiceState();
_recorder?.stop();
_player?.stop();
_ttsQueue.clear();
_ttsPlaying = false;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
_onTranscript = null;
_onError = null;
state = const VoiceState();
} }
/// Feed streaming assistant content for TTS synthesis.
/// Call from screens with the full [fullContent] string on each update.
/// Set [isComplete] to true when the stream has finished.
void feedContent(String fullContent, {required bool isComplete}) { void feedContent(String fullContent, {required bool isComplete}) {
if (!state.voiceModeActive || !_enableTts) return; if (!state.voiceModeActive || !_enableTts) return;
@@ -243,116 +263,131 @@ class VoiceNotifier extends Notifier<VoiceState> {
} }
} }
// ── 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 ────────────────────────────────────────────────────── // ── Internal recording ──────────────────────────────────────────────────────
Future<void> _startListening() async { Future<void> _startListening() async {
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
_silenceMs = 0; _speechDetected = false;
_peakDb = -100.0; _speechStartMs = 0;
_recordingStartMs = DateTime.now().millisecondsSinceEpoch;
state = state.copyWith(mode: VoiceMode.recording);
final dir = _tempDir ?? await getTemporaryDirectory();
final path =
'${dir.path}/voice_rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
try { try {
// Recreate the recorder each session — the record package can leave await _stopVad();
// the native AudioRecord in a bad state after stop/error cycles, _vadHandler = VadHandler.create();
// and a stale instance is the most common cause of "could not start".
_recorder?.dispose();
_recorder = AudioRecorder();
if (!await _recorder!.hasPermission()) { _vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
_onError?.call('Microphone permission was revoked'); if (_disposed || !state.voiceModeActive) return;
exitVoiceMode(); if (!_speechDetected) {
return; _speechDetected = true;
_speechStartMs = DateTime.now().millisecondsSinceEpoch;
} }
});
await _recorder!.start( _vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((audioSamples) {
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000), if (_disposed || !state.voiceModeActive) return;
path: path, final now = DateTime.now().millisecondsSinceEpoch;
); final sinceStart = _speechStartMs > 0 ? now - _speechStartMs : 0;
if (_speechDetected && sinceStart >= _vadGraceMs) {
_amplitudeSubscription?.cancel(); _stopVad();
_amplitudeSubscription = _recorder! _handleSpeechEnd(audioSamples);
.onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude);
} catch (e) {
_onError?.call('Microphone error: $e');
exitVoiceMode();
} }
});
_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);
void _onAmplitude(Amplitude event) { final norm = (rms * 4.0).clamp(0.0, 1.0);
if (!state.voiceModeActive) return;
final db = event.current;
final validDb = !(db.isNaN || db.isInfinite);
// Normalize dB to 0..1 for the pulse animation. -60dB is dead-quiet,
// 0dB is peak; we clamp and bias so the button visibly breathes even
// on soft speech without exploding on loud input.
if (validDb) {
final norm = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
if ((norm - state.amplitude).abs() > 0.02) { if ((norm - state.amplitude).abs() > 0.02) {
state = state.copyWith(amplitude: norm); state = state.copyWith(amplitude: norm);
} }
if (db > _peakDb) _peakDb = db; });
_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) {
final elapsed = _onError?.call('Microphone error: $e');
DateTime.now().millisecondsSinceEpoch - _recordingStartMs; _cleanup();
// Grace period at the start — let the user begin speaking before we if (!_disposed) state = const VoiceState();
// start silence-counting. Also suppresses any false triggers while
// the native recorder is still warming up.
if (elapsed < _graceMs) return;
if (elapsed < _minRecordingMs) return;
// Dynamic threshold once we've seen real speech; static fallback
// before that so a dead-silent session doesn't spin forever.
final threshold =
_peakDb > _dynamicArmDb ? _peakDb - _dropFromPeakDb : _fallbackThresholdDb;
final isSilent = !validDb || db < threshold;
if (isSilent) {
_silenceMs += 200;
if (_silenceMs >= _silenceDurationMs) {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_handleSilence();
}
} else {
_silenceMs = 0;
} }
} }
Future<void> _handleSilence() async { Future<void> _stopVad() async {
if (!state.voiceModeActive) return; _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); state = state.copyWith(mode: VoiceMode.transcribing);
final path = await _recorder!.stop();
if (path == null || !state.voiceModeActive) return;
try { try {
final bytes = await File(path).readAsBytes(); final wavBytes = encodeWav(audioSamples);
await File(path).delete().catchError((_) => File(path));
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
final transcript = await ref.read(voiceRepositoryProvider).transcribe( final transcript = await ref.read(voiceRepositoryProvider).transcribe(
bytes, wavBytes,
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null, context:
_lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
); );
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
if (transcript.isEmpty) { if (transcript.isEmpty) {
_emptyTranscriptCount++; _emptyTranscriptCount++;
if (_emptyTranscriptCount >= 3) { if (_emptyTranscriptCount >= 3) {
_onError?.call('No speech detected — tap the mic to try again'); _onError?.call('No speech detected — tap the mic to try again');
exitVoiceMode(); _cleanup();
if (!_disposed) state = const VoiceState();
return; return;
} }
await _startListening(); await _startListening();
@@ -360,24 +395,25 @@ class VoiceNotifier extends Notifier<VoiceState> {
} }
_emptyTranscriptCount = 0; _emptyTranscriptCount = 0;
// Reset TTS state for this new turn
_sentenceBuffer = ''; _sentenceBuffer = '';
_lastSeenLength = 0; _lastSeenLength = 0;
_streamComplete = false; _streamComplete = false;
if (_enableTts) { if (_enableTts && !_disposed) {
state = state.copyWith(mode: VoiceMode.playing); state = state.copyWith(mode: VoiceMode.playing);
} }
await _onTranscript?.call(transcript); await _onTranscript?.call(transcript);
// If TTS is not enabled, loop immediately // In STT-only mode (no TTS), return to idle after transcript is sent.
if (!_enableTts && state.voiceModeActive) { // The user taps the mic again to record another message.
await _startListening(); if (!_enableTts && !_disposed && state.voiceModeActive) {
state = state.copyWith(mode: VoiceMode.idle);
} }
} catch (e) { } catch (e) {
_onError?.call('Voice error: transcription failed'); _onError?.call('Voice error: transcription failed');
exitVoiceMode(); _cleanup();
if (!_disposed) state = const VoiceState();
} }
} }
@@ -405,7 +441,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
try { try {
final wavBytes = final wavBytes =
await ref.read(voiceRepositoryProvider).synthesise(text); await ref.read(voiceRepositoryProvider).synthesise(text);
if (!state.voiceModeActive) return; if (_disposed || !state.voiceModeActive) return;
_ttsQueue.add(wavBytes); _ttsQueue.add(wavBytes);
if (!_ttsPlaying) _drainTtsQueue(); if (!_ttsPlaying) _drainTtsQueue();
} catch (_) { } catch (_) {
@@ -444,6 +480,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
} }
void _checkRestartListening() { void _checkRestartListening() {
if (_disposed) return;
if (_streamComplete && if (_streamComplete &&
_ttsQueue.isEmpty && _ttsQueue.isEmpty &&
!_ttsPlaying && !_ttsPlaying &&
+6 -5
View File
@@ -1,6 +1,7 @@
import 'dart:io' as io; import 'dart:io' as io;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -72,7 +73,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
_usernameController.text.trim(), _usernameController.text.trim(),
_passwordController.text, _passwordController.text,
); );
if (mounted) context.go(Routes.briefing); if (mounted) context.go(Routes.journal);
} on AuthException catch (e) { } on AuthException catch (e) {
setState(() => _error = e.message); setState(() => _error = e.message);
} on AppException catch (e) { } on AppException catch (e) {
@@ -90,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
cookieJar: ref.read(cookieJarProvider), cookieJar: ref.read(cookieJarProvider),
onSuccess: () async { onSuccess: () async {
await ref.read(authProvider.notifier).verify(); 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), const SizedBox(height: 32),
if (_oauthEnabled) ...[ if (_oauthEnabled) ...[
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.login), icon: const Icon(LucideIcons.logIn),
label: const Text('Sign in with SSO'), label: const Text('Sign in with SSO'),
onPressed: _openOAuth, onPressed: _openOAuth,
), ),
@@ -155,8 +156,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_obscure icon: Icon(_obscure
? Icons.visibility ? LucideIcons.eye
: Icons.visibility_off), : LucideIcons.eyeOff),
onPressed: () => onPressed: () =>
setState(() => _obscure = !_obscure), 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);
});
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:table_calendar/table_calendar.dart'; import 'package:table_calendar/table_calendar.dart';
@@ -91,7 +92,7 @@ class CalendarScreen extends ConsumerWidget {
notifier: notifier, notifier: notifier,
), ),
), ),
child: const Icon(Icons.add), child: const Icon(LucideIcons.plus),
) )
: null, : null,
); );
+22 -18
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme.dart';
import '../../data/models/calendar_event.dart'; import '../../data/models/calendar_event.dart';
import '../../providers/api_client_provider.dart'; import '../../providers/api_client_provider.dart';
import '../../providers/calendar_provider.dart'; import '../../providers/calendar_provider.dart';
@@ -118,12 +120,12 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
'color': _color, 'color': _color,
'recurrence': rrule, 'recurrence': rrule,
}; };
final api = ref.read(eventsApiProvider); final repo = ref.read(eventsRepositoryProvider);
if (_isCreate) { if (_isCreate) {
final created = await api.createEvent(payload); final created = await repo.createEvent(payload);
widget.notifier.addEvent(created); widget.notifier.addEvent(created);
} else { } else {
final updated = await api.updateEvent(widget.event!.id, payload); final updated = await repo.updateEvent(widget.event!.id, payload);
widget.notifier.updateEvent(updated); widget.notifier.updateEvent(updated);
} }
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
@@ -152,11 +154,10 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: Text( style: TextButton.styleFrom(
'Delete', foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
style: TextStyle(
color: Theme.of(dialogContext).colorScheme.error),
), ),
child: const Text('Delete'),
), ),
], ],
), ),
@@ -164,7 +165,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
if (confirmed != true || !mounted) return; if (confirmed != true || !mounted) return;
setState(() => _saving = true); setState(() => _saving = true);
try { try {
await ref.read(eventsApiProvider).deleteEvent(widget.event!.id); await ref.read(eventsRepositoryProvider).deleteEvent(widget.event!.id);
widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt); widget.notifier.removeEvent(widget.event!.id, widget.event!.startDt);
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} catch (_) { } catch (_) {
@@ -273,7 +274,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
const Spacer(), const Spacer(),
if (!_isCreate) if (!_isCreate)
IconButton( IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(LucideIcons.trash2),
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
onPressed: _saving ? null : _delete, onPressed: _saving ? null : _delete,
), ),
@@ -303,7 +304,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
// Start date // Start date
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined), leading: const Icon(LucideIcons.calendarDays),
title: Text(_fmtDate(_startDt)), title: Text(_fmtDate(_startDt)),
subtitle: const Text('Start date'), subtitle: const Text('Start date'),
onTap: _pickStartDate, onTap: _pickStartDate,
@@ -313,7 +314,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
if (!_allDay) if (!_allDay)
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time_outlined), leading: const Icon(LucideIcons.clock),
title: Text(_fmtTime(_startDt)), title: Text(_fmtTime(_startDt)),
subtitle: const Text('Start time'), subtitle: const Text('Start time'),
onTap: _pickStartTime, onTap: _pickStartTime,
@@ -322,14 +323,14 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
// End date // End date
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined), leading: const Icon(LucideIcons.calendarCheck),
title: Text( title: Text(
_endDt != null ? _fmtDate(_endDt!) : 'No end date'), _endDt != null ? _fmtDate(_endDt!) : 'No end date'),
subtitle: const Text('End date'), subtitle: const Text('End date'),
onTap: _pickEndDate, onTap: _pickEndDate,
trailing: _endDt != null trailing: _endDt != null
? IconButton( ? IconButton(
icon: const Icon(Icons.clear), icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _endDt = null), onPressed: () => setState(() => _endDt = null),
) )
: null, : null,
@@ -339,7 +340,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
if (!_allDay && _endDt != null) if (!_allDay && _endDt != null)
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time_outlined), leading: const Icon(LucideIcons.clock),
title: Text(_fmtTime(_endDt!)), title: Text(_fmtTime(_endDt!)),
subtitle: const Text('End time'), subtitle: const Text('End time'),
onTap: _pickEndTime, onTap: _pickEndTime,
@@ -351,14 +352,14 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
if (_isCustomRrule) if (_isCustomRrule)
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.repeat_outlined), leading: const Icon(LucideIcons.repeat),
title: const Text('Custom (read-only)'), title: const Text('Custom (read-only)'),
subtitle: Text(widget.event!.recurrence ?? ''), subtitle: Text(widget.event!.recurrence ?? ''),
) )
else else
Row( Row(
children: [ children: [
const Icon(Icons.repeat_outlined), const Icon(LucideIcons.repeat),
const SizedBox(width: 16), const SizedBox(width: 16),
DropdownButton<String?>( DropdownButton<String?>(
value: _recurrence, value: _recurrence,
@@ -419,7 +420,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
width: _color.isEmpty ? 2 : 1, width: _color.isEmpty ? 2 : 1,
), ),
), ),
child: const Icon(Icons.block, size: 16), child: const Icon(LucideIcons.ban, size: 16),
), ),
), ),
..._presetColors.map((hex) { ..._presetColors.map((hex) {
@@ -451,10 +452,13 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
const SizedBox(height: 24), const SizedBox(height: 24),
// Save button // Save button — Moss action-primary per Hybrid rule
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton( child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).extension<ActionColors>()!.primary,
),
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
child: _saving child: _saving
? const SizedBox( ? const SizedBox(
+3 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart'; import '../../core/exceptions.dart';
@@ -68,8 +69,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_controller.dispose(); _controller.dispose();
_scrollController.dispose(); _scrollController.dispose();
// Exit voice mode if the user navigates away mid-session.
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose(); super.dispose();
} }
@@ -175,7 +174,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.refresh), : const Icon(LucideIcons.refreshCw),
), ),
], ],
), ),
@@ -282,7 +281,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
child: child:
CircularProgressIndicator(strokeWidth: 2), CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.send), : const Icon(LucideIcons.send),
), ),
], ],
), ),
+105 -48
View File
@@ -1,33 +1,87 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../core/theme.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
class ConversationsTabScreen extends ConsumerWidget { class ConversationsTabScreen extends ConsumerStatefulWidget {
const ConversationsTabScreen({super.key}); const ConversationsTabScreen({super.key});
@override @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 theme = Theme.of(context);
final convsAsync = ref.watch(conversationsProvider); final convsAsync = ref.watch(conversationsProvider);
return Scaffold( final listPanel = Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Chat', style: theme.textTheme.titleLarge), title: Text('Chat', style: theme.textTheme.titleLarge),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.add), icon: const Icon(LucideIcons.plus),
tooltip: 'New conversation', tooltip: 'New conversation',
onPressed: () async { onPressed: _createConversation,
final conv = await ref
.read(conversationsProvider.notifier)
.create('');
if (context.mounted) {
context.push(Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
), ),
], ],
), ),
@@ -40,37 +94,31 @@ class ConversationsTabScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.chat_bubble_outline, Icon(LucideIcons.messageCircle,
size: 48, color: theme.colorScheme.onSurfaceVariant), size: 48, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('No conversations yet', Text('No conversations yet',
style: theme.textTheme.titleMedium), style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.add), icon: const Icon(LucideIcons.plus),
label: const Text('Start a conversation'), label: const Text('Start a conversation'),
onPressed: () async { onPressed: _createConversation,
final conv = await ref
.read(conversationsProvider.notifier)
.create('');
if (context.mounted) {
context.push(
Routes.chat.replaceFirst(':id', '${conv.id}'));
}
},
), ),
], ],
), ),
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(conversationsProvider.notifier).refresh(), onRefresh: () =>
ref.read(conversationsProvider.notifier).refresh(),
child: ListView.builder( child: ListView.builder(
itemCount: convs.length, itemCount: convs.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final c = convs[i]; final c = convs[i];
final selected = isWide && c.id == _selectedConvId;
return ListTile( return ListTile(
leading: const Icon(Icons.chat_bubble_outline), leading: const Icon(LucideIcons.messageCircle),
title: Text( title: Text(
c.title.isEmpty ? 'New conversation' : c.title, c.title.isEmpty ? 'New conversation' : c.title,
maxLines: 1, maxLines: 1,
@@ -79,13 +127,12 @@ class ConversationsTabScreen extends ConsumerWidget {
_relativeTime(c.updatedAt), _relativeTime(c.updatedAt),
style: theme.textTheme.labelSmall, style: theme.textTheme.labelSmall,
), ),
selected: selected,
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(LucideIcons.trash2),
onPressed: () => onPressed: () => _confirmDelete(c.id, c.title),
_confirmDelete(context, ref, c.id, c.title),
), ),
onTap: () => onTap: () => _openConversation(c.id),
ctx.push(Routes.chat.replaceFirst(':id', '${c.id}')),
); );
}, },
), ),
@@ -93,28 +140,38 @@ class ConversationsTabScreen extends ConsumerWidget {
}, },
), ),
); );
}
Future<void> _confirmDelete( if (!isWide) return listPanel;
BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>( return Row(
context: context, children: [
builder: (dialogContext) => AlertDialog( SizedBox(
title: const Text('Delete conversation?'), width: 320,
content: Text('"$title" will be permanently deleted.'), child: listPanel,
actions: [ ),
TextButton( const VerticalDivider(width: 1),
onPressed: () => Navigator.pop(dialogContext, false), Expanded(
child: const Text('Cancel')), child: _selectedConvId != null
FilledButton( ? ChatScreen(
onPressed: () => Navigator.pop(dialogContext, true), key: ValueKey(_selectedConvId),
child: const Text('Delete')), 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);
});
@@ -1,33 +1,29 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart'; import '../../core/exceptions.dart';
import '../../data/models/message.dart'; import '../../data/models/message.dart';
import '../../providers/briefing_provider.dart'; import '../../providers/journal_provider.dart';
import '../../providers/api_client_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/weather_card.dart';
import '../../widgets/news_card.dart';
import 'briefing_history_screen.dart';
import '../../providers/voice_provider.dart'; import '../../providers/voice_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/voice_mic_button.dart'; import '../../widgets/voice_mic_button.dart';
import 'journal_history_screen.dart';
class BriefingScreen extends ConsumerStatefulWidget { class JournalScreen extends ConsumerStatefulWidget {
const BriefingScreen({super.key}); const JournalScreen({super.key});
@override @override
ConsumerState<BriefingScreen> createState() => _BriefingScreenState(); ConsumerState<JournalScreen> createState() => _JournalScreenState();
} }
class _BriefingScreenState extends ConsumerState<BriefingScreen> class _JournalScreenState extends ConsumerState<JournalScreen>
with WidgetsBindingObserver { with WidgetsBindingObserver {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
bool _refreshing = false; bool _refreshing = false;
// rss_item_id -> 'up' | 'down' | null
final Map<int, String?> _reactions = {};
Timer? _pollTimer; Timer? _pollTimer;
bool _appInForeground = true; bool _appInForeground = true;
@@ -36,31 +32,29 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently()); _pollTimer =
Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
} }
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
final wasBackground = !_appInForeground; final wasBackground = !_appInForeground;
_appInForeground = state == AppLifecycleState.resumed; _appInForeground = state == AppLifecycleState.resumed;
// On resume, force a message refresh. This also unfreezes a stuck
// streaming state if the SSE socket died while the app was backgrounded
// silentRefresh won't do that because it guards on isStreaming.
if (_appInForeground && wasBackground && mounted) { if (_appInForeground && wasBackground && mounted) {
ref.read(briefingProvider.notifier).refreshMessages(); ref.read(journalProvider.notifier).refreshMessages();
} }
} }
void _pollSilently() { void _pollSilently() {
if (!_appInForeground || !mounted) return; if (!_appInForeground || !mounted) return;
final isStreaming = ref.read(isBriefingStreamingProvider); final isStreaming = ref.read(isJournalStreamingProvider);
if (isStreaming) return; if (isStreaming) return;
ref.read(briefingProvider.notifier).silentRefresh(); ref.read(journalProvider.notifier).silentRefresh();
} }
Future<void> _pullToRefresh() async { Future<void> _pullToRefresh() async {
try { try {
await ref.read(briefingProvider.notifier).refreshMessages(); await ref.read(journalProvider.notifier).refreshMessages();
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -76,7 +70,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_controller.dispose(); _controller.dispose();
_scrollController.dispose(); _scrollController.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose(); super.dispose();
} }
@@ -97,7 +90,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
if (text.isEmpty) return; if (text.isEmpty) return;
_controller.clear(); _controller.clear();
try { try {
await ref.read(briefingProvider.notifier).sendReply(text); await ref.read(journalProvider.notifier).sendReply(text);
} on AppException catch (e) { } on AppException catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
@@ -112,39 +105,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
} }
} }
Future<void> _handleDiscuss(int convId, int itemId) async {
try {
await ref.read(briefingProvider.notifier).discussArticle(convId, itemId);
} on AppException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to start discussion.')),
);
}
}
}
Future<void> _handleReaction(int itemId, String reaction) async {
final current = _reactions[itemId];
final next = current == reaction ? null : reaction;
setState(() => _reactions[itemId] = next);
final api = ref.read(briefingApiProvider);
try {
if (next == null) {
await api.deleteRssReaction(itemId);
} else {
await api.postRssReaction(itemId, reaction);
}
} catch (_) {
setState(() => _reactions[itemId] = current);
}
}
Future<void> _toggleVoiceMode() async { Future<void> _toggleVoiceMode() async {
final voice = ref.read(voiceProvider); final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) { if (voice.voiceModeActive) {
@@ -153,7 +113,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
} }
await ref.read(voiceProvider.notifier).enterVoiceMode( await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async { onTranscript: (transcript) async {
await ref.read(briefingProvider.notifier).sendReply(transcript); await ref.read(journalProvider.notifier).sendReply(transcript);
}, },
enableTts: true, enableTts: true,
onError: (msg) { onError: (msg) {
@@ -168,11 +128,11 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
Future<void> _refresh() async { Future<void> _refresh() async {
setState(() => _refreshing = true); setState(() => _refreshing = true);
try { try {
await ref.read(briefingProvider.notifier).refresh('compilation'); await ref.read(journalProvider.notifier).regeneratePrep();
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not generate briefing.')), const SnackBar(content: Text('Could not regenerate prep.')),
); );
} }
} finally { } finally {
@@ -182,20 +142,19 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final briefingAsync = ref.watch(briefingProvider); final journalAsync = ref.watch(journalProvider);
final isStreaming = ref.watch(isBriefingStreamingProvider); final isStreaming = ref.watch(isJournalStreamingProvider);
final voiceState = ref.watch(voiceProvider); final voiceState = ref.watch(voiceProvider);
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
// Scroll to bottom when messages change ref.listen(journalProvider, (prev, next) => _scrollToBottom());
ref.listen(briefingProvider, (prev, next) => _scrollToBottom());
// Feed streaming assistant content to VoiceNotifier for TTS. // Feed streaming assistant content to VoiceNotifier for TTS.
ref.listen(briefingProvider, (prev, next) { ref.listen(journalProvider, (prev, next) {
if (!voiceState.voiceModeActive) return; if (!voiceState.voiceModeActive) return;
final conv = next.value; final day = next.value;
if (conv == null || conv.messages.isEmpty) return; if (day == null || day.messages.isEmpty) return;
final last = conv.messages.last; final last = day.messages.last;
if (last.role != MessageRole.assistant) return; if (last.role != MessageRole.assistant) return;
final isComplete = last.status != 'generating'; final isComplete = last.status != 'generating';
ref ref
@@ -208,7 +167,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Briefing', style: Theme.of(context).textTheme.titleLarge), Text('Journal', style: Theme.of(context).textTheme.titleLarge),
Text( Text(
_todayLabel(), _todayLabel(),
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
@@ -229,62 +188,61 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
) )
else else
IconButton( IconButton(
icon: const Icon(Icons.refresh_outlined), icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Generate briefing', tooltip: 'Regenerate prep',
onPressed: _refresh, onPressed: _refresh,
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
onSelected: (value) { onSelected: (value) {
if (value == 'history') { if (value == 'history') {
Navigator.of(context).push(MaterialPageRoute( Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const BriefingHistoryScreen(), builder: (_) => const JournalHistoryScreen(),
)); ));
} }
}, },
itemBuilder: (_) => const [ itemBuilder: (_) => const [
PopupMenuItem( PopupMenuItem(
value: 'history', value: 'history',
child: Text('View past briefings'), child: Text('Past days'),
), ),
], ],
), ),
], ],
), ),
body: briefingAsync.when( body: journalAsync.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center( error: (err, stack) => Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text("Could not load today's briefing."), const Text("Could not load today's journal."),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.tonal( FilledButton.tonal(
onPressed: () => ref.invalidate(briefingProvider), onPressed: () => ref.invalidate(journalProvider),
child: const Text('Retry'), child: const Text('Retry'),
), ),
], ],
), ),
), ),
data: (conv) { data: (day) {
return Column( final isWide = MediaQuery.of(context).size.width >= 600;
Widget body = Column(
children: [ children: [
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _pullToRefresh, onRefresh: _pullToRefresh,
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: _scrollController,
// AlwaysScrollable so pull-to-refresh fires even when
// the briefing is empty or shorter than the viewport.
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
if (conv.messages.isEmpty) if (day.messages.isEmpty)
SliverFillRemaining( SliverFillRemaining(
child: Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'No briefing yet today.', 'No prep yet today.',
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodyMedium .bodyMedium
@@ -304,32 +262,20 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8), horizontal: 8, vertical: 8),
sliver: SliverList.builder( sliver: SliverList.builder(
itemCount: conv.messages.length, itemCount: day.messages.length,
itemBuilder: (_, i) { itemBuilder: (_, i) =>
final msg = conv.messages[i]; _JournalMessageItem(message: day.messages[i]),
return _BriefingMessageItem(
message: msg,
convId: conv.id,
reactions: _reactions,
onReaction: _handleReaction,
onDiscuss: _handleDiscuss,
);
},
), ),
), ),
], ],
), ),
), ),
), ),
// Progress bar while streaming
if (isStreaming) if (isStreaming)
LinearProgressIndicator( LinearProgressIndicator(
minHeight: 2, minHeight: 2,
color: scheme.primary, color: scheme.primary,
), ),
// Voice mode banner
if (voiceState.voiceModeActive) if (voiceState.voiceModeActive)
Container( Container(
width: double.infinity, width: double.infinity,
@@ -344,7 +290,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
), ),
), ),
), ),
// Reply bar
const Divider(height: 1), const Divider(height: 1),
SafeArea( SafeArea(
child: Padding( child: Padding(
@@ -357,7 +302,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
decoration: InputDecoration( decoration: InputDecoration(
hintText: voiceState.voiceModeActive hintText: voiceState.voiceModeActive
? 'Listening…' ? 'Listening…'
: 'Reply to your briefing', : 'Tell your journal',
hintStyle: voiceState.voiceModeActive hintStyle: voiceState.voiceModeActive
? const TextStyle(fontStyle: FontStyle.italic) ? const TextStyle(fontStyle: FontStyle.italic)
: null, : null,
@@ -392,6 +337,15 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
), ),
], ],
); );
if (isWide) {
body = Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: body,
),
);
}
return body;
}, },
), ),
); );
@@ -411,62 +365,21 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
} }
} }
/// Renders a single briefing message with optional WeatherCard above it /// Renders a single journal message. The daily-prep prose itself already
/// and RSS reaction buttons below it (for assistant messages with metadata). /// covers weather ("Weather at home will reach a high of 15.9° ..."), so
class _BriefingMessageItem extends StatelessWidget { /// the journal screen leaves rendering to the message bubble no separate
/// weather card on top.
class _JournalMessageItem extends StatelessWidget {
final Message message; final Message message;
final int convId;
final Map<int, String?> reactions;
final void Function(int itemId, String reaction) onReaction;
final void Function(int convId, int itemId) onDiscuss;
const _BriefingMessageItem({ const _JournalMessageItem({required this.message});
required this.message,
required this.convId,
required this.reactions,
required this.onReaction,
required this.onDiscuss,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final meta = message.metadata; return ChatMessageBubble(message: message);
final isAssistant = message.role == MessageRole.assistant;
// Weather: show card above when metadata.weather key is present (even if null value)
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
// RSS news cards cap at 3
final rssItemsRaw = isAssistant && meta != null
? (meta['rss_items'] as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? []
: <Map<String, dynamic>>[];
final rssItems = rssItemsRaw.map(RssItemMeta.fromJson).take(3).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasWeatherKey) WeatherCard(weather: weatherData),
ChatMessageBubble(message: message),
if (rssItems.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rssItems.map((item) => NewsCard(
item: item,
reaction: reactions[item.id],
onReaction: onReaction,
onDiscuss: () => onDiscuss(convId, item.id),
)).toList(),
),
),
],
);
} }
} }
class _GradientSendButton extends StatelessWidget { class _GradientSendButton extends StatelessWidget {
final VoidCallback? onPressed; final VoidCallback? onPressed;
final bool isStreaming; final bool isStreaming;
@@ -488,7 +401,7 @@ class _GradientSendButton extends StatelessWidget {
: const LinearGradient( : const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)], colors: [Color(0xFF5B4A8A), Color(0xFF3F3560)],
), ),
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null, color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
@@ -504,7 +417,7 @@ class _GradientSendButton extends StatelessWidget {
), ),
) )
: Icon( : Icon(
Icons.send, LucideIcons.send,
color: disabled color: disabled
? scheme.onSurface.withValues(alpha: 0.38) ? scheme.onSurface.withValues(alpha: 0.38)
: Colors.white, : Colors.white,
+47 -7
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -95,7 +96,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
: const Text('Knowledge'), : const Text('Knowledge'),
actions: [ actions: [
IconButton( IconButton(
icon: Icon(_searchActive ? Icons.close : Icons.search), icon: Icon(_searchActive ? LucideIcons.x : LucideIcons.search),
onPressed: () { onPressed: () {
setState(() => _searchActive = !_searchActive); setState(() => _searchActive = !_searchActive);
if (!_searchActive) { if (!_searchActive) {
@@ -149,7 +150,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _onFabTapped(context), onPressed: () => _onFabTapped(context),
tooltip: 'New', tooltip: 'New',
child: const Icon(Icons.edit_outlined), child: const Icon(LucideIcons.pencil),
), ),
); );
} }
@@ -186,7 +187,15 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(), onRefresh: () => ref.read(knowledgeProvider.notifier).refresh(),
child: ListView.separated( child: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth >= 900
? 3
: constraints.maxWidth >= 600
? 2
: 1;
if (cols == 1) {
return ListView.separated(
controller: _scrollController, controller: _scrollController,
itemCount: items.length + (state.isLoadingBatch ? 1 : 0), itemCount: items.length + (state.isLoadingBatch ? 1 : 0),
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
@@ -199,6 +208,37 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
} }
return KnowledgeItemCard(item: items[i]); 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),
),
),
),
],
);
},
), ),
); );
} }
@@ -220,7 +260,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_TypePickerRow( _TypePickerRow(
icon: Icons.description_outlined, icon: LucideIcons.fileText,
label: 'Note', label: 'Note',
description: 'General note or document', description: 'General note or document',
onTap: () { onTap: () {
@@ -229,7 +269,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
}, },
), ),
_TypePickerRow( _TypePickerRow(
icon: Icons.person_outlined, icon: LucideIcons.user,
label: 'Person', label: 'Person',
description: 'Contact, colleague, or reference person', description: 'Contact, colleague, or reference person',
onTap: () { onTap: () {
@@ -238,7 +278,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
}, },
), ),
_TypePickerRow( _TypePickerRow(
icon: Icons.place_outlined, icon: LucideIcons.mapPin,
label: 'Place', label: 'Place',
description: 'Location, venue, or place of interest', description: 'Location, venue, or place of interest',
onTap: () { onTap: () {
@@ -247,7 +287,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
}, },
), ),
_TypePickerRow( _TypePickerRow(
icon: Icons.checklist_outlined, icon: LucideIcons.listChecks,
label: 'List', label: 'List',
description: 'Checklist or structured list', description: 'Checklist or structured list',
onTap: () { onTap: () {
+21 -8
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../data/local/database.dart';
import '../../data/models/milestone.dart'; import '../../data/models/milestone.dart';
import '../../data/models/project.dart'; import '../../data/models/project.dart';
import '../../data/models/task.dart'; import '../../data/models/task.dart';
@@ -10,6 +12,7 @@ import '../../providers/api_client_provider.dart';
import '../../providers/milestones_provider.dart'; import '../../providers/milestones_provider.dart';
import '../../providers/projects_provider.dart'; import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
import '../../widgets/pending_sync_badge.dart';
class ProjectTasksScreen extends ConsumerStatefulWidget { class ProjectTasksScreen extends ConsumerStatefulWidget {
final int projectId; final int projectId;
@@ -55,11 +58,11 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
} }
Color _parseColor(String? hex) { Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF7C3AED); if (hex == null || hex.isEmpty) return const Color(0xFF5B4A8A);
try { try {
return Color(int.parse(hex.replaceFirst('#', '0xFF'))); return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) { } catch (_) {
return const Color(0xFF7C3AED); return const Color(0xFF5B4A8A);
} }
} }
@@ -96,7 +99,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
), ),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined), icon: const Icon(LucideIcons.pencil),
tooltip: 'Edit project', tooltip: 'Edit project',
onPressed: () => onPressed: () =>
context.push('/projects/${widget.projectId}/edit'), context.push('/projects/${widget.projectId}/edit'),
@@ -133,7 +136,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.check_box_outlined, Icon(LucideIcons.checkSquare,
size: 48, size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant), color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -302,9 +305,9 @@ class _TaskRow extends StatelessWidget {
}); });
IconData get _statusIcon => switch (effectiveStatus) { IconData get _statusIcon => switch (effectiveStatus) {
TaskStatus.done => Icons.check_circle, TaskStatus.done => LucideIcons.checkCircle2,
TaskStatus.inProgress => Icons.timelapse, TaskStatus.inProgress => LucideIcons.loader,
TaskStatus.todo => Icons.radio_button_unchecked, TaskStatus.todo => LucideIcons.circle,
}; };
Color _statusColor(BuildContext context) { Color _statusColor(BuildContext context) {
@@ -345,7 +348,10 @@ class _TaskRow extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
children: [
Flexible(
child: Text(
task.title.isNotEmpty ? task.title : 'Untitled', task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
decoration: effectiveStatus == TaskStatus.done decoration: effectiveStatus == TaskStatus.done
@@ -358,6 +364,13 @@ class _TaskRow extends StatelessWidget {
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
),
PendingSyncBadge(
domain: kSyncDomainTasks,
id: task.id,
),
],
),
if (task.dueDate != null) ...[ if (task.dueDate != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
-187
View File
@@ -1,187 +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/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: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Could not load news.'),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(newsProvider),
child: const Text('Retry'),
),
],
),
),
data: (news) => Column(
children: [
_FeedFilter(
feeds: feedsAsync.value ?? [],
selectedFeedId: news.selectedFeedId,
onChanged: (feedId) =>
ref.read(newsProvider.notifier).setFeed(feedId),
),
const Divider(height: 1),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(newsProvider.notifier).refresh(),
child: 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),
),
],
),
);
}
}
+9 -2
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../core/theme.dart';
import '../../core/wikilink_syntax.dart'; import '../../core/wikilink_syntax.dart';
import '../../providers/notes_provider.dart'; import '../../providers/notes_provider.dart';
@@ -44,13 +46,13 @@ class NoteDetailScreen extends ConsumerWidget {
data: (note) => Row( data: (note) => Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.edit), icon: const Icon(LucideIcons.pencil),
onPressed: () => context.push( onPressed: () => context.push(
Routes.noteEdit.replaceFirst(':id', '$noteId'), Routes.noteEdit.replaceFirst(':id', '$noteId'),
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.delete), icon: const Icon(LucideIcons.trash2),
onPressed: () async { onPressed: () async {
final confirm = await showDialog<bool>( final confirm = await showDialog<bool>(
context: context, context: context,
@@ -65,6 +67,11 @@ class NoteDetailScreen extends ConsumerWidget {
TextButton( TextButton(
onPressed: () => onPressed: () =>
Navigator.pop(dialogContext, true), Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext)
.extension<ActionColors>()!
.destructive,
),
child: const Text('Delete'), child: const Text('Delete'),
), ),
], ],
+9 -4
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart'; import '../../core/exceptions.dart';
import '../../core/theme.dart';
import '../../core/wikilink_syntax.dart'; import '../../core/wikilink_syntax.dart';
import '../../providers/api_client_provider.dart'; import '../../providers/api_client_provider.dart';
import '../../providers/notes_provider.dart'; import '../../providers/notes_provider.dart';
@@ -85,6 +87,9 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete'), child: const Text('Delete'),
), ),
], ],
@@ -163,12 +168,12 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
actions: [ actions: [
if (widget.noteId != null) if (widget.noteId != null)
IconButton( IconButton(
icon: const Icon(Icons.delete_outline), icon: const Icon(LucideIcons.trash2),
tooltip: 'Delete', tooltip: 'Delete',
onPressed: _delete, onPressed: _delete,
), ),
IconButton( IconButton(
icon: Icon(_preview ? Icons.edit : Icons.preview), icon: Icon(_preview ? LucideIcons.pencil : LucideIcons.eye),
tooltip: _preview ? 'Edit' : 'Preview', tooltip: _preview ? 'Edit' : 'Preview',
onPressed: () => setState(() => _preview = !_preview), onPressed: () => setState(() => _preview = !_preview),
), ),
@@ -179,7 +184,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.check), : const Icon(LucideIcons.check),
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
), ),
], ],
@@ -272,7 +277,7 @@ class _TagInput extends StatelessWidget {
label: Text('#$tag', style: const TextStyle(fontSize: 12)), label: Text('#$tag', style: const TextStyle(fontSize: 12)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
deleteIcon: const Icon(Icons.close, size: 14), deleteIcon: const Icon(LucideIcons.x, size: 14),
onDeleted: () => onRemove(tag), onDeleted: () => onRemove(tag),
), ),
), ),
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -105,7 +106,7 @@ class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.check), : const Icon(LucideIcons.check),
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
), ),
], ],
+10 -2
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../data/local/database.dart';
import '../../data/models/project.dart'; import '../../data/models/project.dart';
import '../../providers/projects_provider.dart'; import '../../providers/projects_provider.dart';
import '../../widgets/pending_sync_badge.dart';
class ProjectsScreen extends ConsumerWidget { class ProjectsScreen extends ConsumerWidget {
const ProjectsScreen({super.key}); const ProjectsScreen({super.key});
@@ -30,7 +33,7 @@ class ProjectsScreen extends ConsumerWidget {
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/projects/new'), onPressed: () => context.push('/projects/new'),
tooltip: 'New project', tooltip: 'New project',
child: const Icon(Icons.add), child: const Icon(LucideIcons.plus),
), ),
); );
} }
@@ -49,7 +52,12 @@ class _ProjectCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
title: Text(project.title), title: Row(
children: [
Flexible(child: Text(project.title)),
PendingSyncBadge(domain: kSyncDomainProjects, id: project.id),
],
),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+15 -14
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -24,28 +25,28 @@ class SettingsScreen extends ConsumerWidget {
ListTile( ListTile(
title: const Text('Server URL'), title: const Text('Server URL'),
subtitle: Text(serverUrl ?? 'Not configured'), subtitle: Text(serverUrl ?? 'Not configured'),
leading: const Icon(Icons.dns), leading: const Icon(LucideIcons.server),
onTap: () => context.go(Routes.setup), onTap: () => context.go(Routes.setup),
), ),
const Divider(), const Divider(),
ListTile( ListTile(
title: const Text('Appearance'), title: const Text('Appearance'),
leading: const Icon(Icons.brightness_6), leading: const Icon(LucideIcons.sunMoon),
trailing: SegmentedButton<ThemeMode>( trailing: SegmentedButton<ThemeMode>(
segments: const [ segments: const [
ButtonSegment( ButtonSegment(
value: ThemeMode.system, value: ThemeMode.system,
icon: Icon(Icons.brightness_auto), icon: Icon(LucideIcons.contrast),
tooltip: 'System', tooltip: 'System',
), ),
ButtonSegment( ButtonSegment(
value: ThemeMode.light, value: ThemeMode.light,
icon: Icon(Icons.light_mode), icon: Icon(LucideIcons.sun),
tooltip: 'Light', tooltip: 'Light',
), ),
ButtonSegment( ButtonSegment(
value: ThemeMode.dark, value: ThemeMode.dark,
icon: Icon(Icons.dark_mode), icon: Icon(LucideIcons.moon),
tooltip: 'Dark', tooltip: 'Dark',
), ),
], ],
@@ -58,7 +59,7 @@ class SettingsScreen extends ConsumerWidget {
// ── Updates ────────────────────────────────────────────────────── // ── Updates ──────────────────────────────────────────────────────
ListTile( ListTile(
leading: const Icon(Icons.update), leading: const Icon(LucideIcons.refreshCw),
title: const Text('Update repository'), title: const Text('Update repository'),
subtitle: Text(repoUrl?.isNotEmpty == true subtitle: Text(repoUrl?.isNotEmpty == true
? repoUrl! ? repoUrl!
@@ -66,7 +67,7 @@ class SettingsScreen extends ConsumerWidget {
onTap: () => _editRepoUrl(context, ref, repoUrl), onTap: () => _editRepoUrl(context, ref, repoUrl),
), ),
ListTile( ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(LucideIcons.info),
title: const Text('App version'), title: const Text('App version'),
subtitle: _versionSubtitle(update), subtitle: _versionSubtitle(update),
trailing: update.status == UpdateStatus.checking trailing: update.status == UpdateStatus.checking
@@ -92,13 +93,13 @@ class SettingsScreen extends ConsumerWidget {
child: const Text('Check'), child: const Text('Check'),
), ),
), ),
if (update.status == UpdateStatus.available || if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading) update.status == UpdateStatus.downloading)
_UpdateTile(update: update), _UpdateTile(update: update),
if (update.status == UpdateStatus.error) if (update.status == UpdateStatus.error)
ListTile( ListTile(
leading: leading:
const Icon(Icons.error_outline, color: Colors.red), const Icon(LucideIcons.alertCircle, color: Colors.red),
title: const Text('Update check failed'), title: const Text('Update check failed'),
subtitle: Text( subtitle: Text(
update.errorMessage ?? 'Unknown error', update.errorMessage ?? 'Unknown error',
@@ -110,7 +111,7 @@ class SettingsScreen extends ConsumerWidget {
const Divider(), const Divider(),
ListTile( ListTile(
title: const Text('Sign Out'), title: const Text('Sign Out'),
leading: const Icon(Icons.logout), leading: const Icon(LucideIcons.logOut),
onTap: () async { onTap: () async {
await ref.read(authProvider.notifier).logout(); await ref.read(authProvider.notifier).logout();
if (context.mounted) context.go(Routes.login); if (context.mounted) context.go(Routes.login);
@@ -131,7 +132,7 @@ class SettingsScreen extends ConsumerWidget {
if (update.status == UpdateStatus.upToDate) { if (update.status == UpdateStatus.upToDate) {
return Text('v$current — up to date'); return Text('v$current — up to date');
} }
if (update.status == UpdateStatus.available || if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading) { update.status == UpdateStatus.downloading) {
return Text('v$current installed'); return Text('v$current installed');
} }
@@ -184,7 +185,7 @@ class _UpdateTile extends ConsumerWidget {
final isDownloading = update.status == UpdateStatus.downloading; final isDownloading = update.status == UpdateStatus.downloading;
return ListTile( 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'), title: Text('v${update.latestVersion} available'),
subtitle: isDownloading subtitle: isDownloading
? Column( ? Column(
@@ -202,12 +203,12 @@ class _UpdateTile extends ConsumerWidget {
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'), '${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
], ],
) )
: const Text('Tap to download and install'), : const Text('Ready to install'),
trailing: isDownloading trailing: isDownloading
? null ? null
: FilledButton( : FilledButton(
onPressed: () => onPressed: () =>
ref.read(updateProvider.notifier).downloadAndInstall(), ref.read(updateProvider.notifier).install(),
child: const Text('Install'), child: const Text('Install'),
), ),
); );
+6 -1
View File
@@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
await ref.read(authProvider.notifier).verify(); await ref.read(authProvider.notifier).verify();
if (!mounted) return; if (!mounted) return;
final status = ref.read(authProvider); final status = ref.read(authProvider);
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
if (status == AuthStatus.authenticated) { 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 { } else {
context.go(Routes.login); context.go(Routes.login);
} }
+10 -5
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../core/exceptions.dart'; import '../../core/exceptions.dart';
import '../../core/theme.dart';
import '../../data/models/task.dart'; import '../../data/models/task.dart';
import '../../providers/api_client_provider.dart'; import '../../providers/api_client_provider.dart';
import '../../providers/tasks_provider.dart'; import '../../providers/tasks_provider.dart';
@@ -111,6 +113,9 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete')), child: const Text('Delete')),
], ],
), ),
@@ -190,7 +195,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
actions: [ actions: [
if (widget.taskId != null) if (widget.taskId != null)
IconButton( IconButton(
icon: const Icon(Icons.delete), icon: const Icon(LucideIcons.trash2),
onPressed: _delete, onPressed: _delete,
), ),
IconButton( IconButton(
@@ -200,7 +205,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.check), : const Icon(LucideIcons.check),
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
), ),
], ],
@@ -272,10 +277,10 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
title: Text(_dueDate == null title: Text(_dueDate == null
? 'No due date' ? 'No due date'
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'), : 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
leading: const Icon(Icons.calendar_today), leading: const Icon(LucideIcons.calendarDays),
trailing: _dueDate != null trailing: _dueDate != null
? IconButton( ? IconButton(
icon: const Icon(Icons.clear), icon: const Icon(LucideIcons.x),
onPressed: () => onPressed: () =>
setState(() => _dueDate = null), setState(() => _dueDate = null),
) )
@@ -331,7 +336,7 @@ class _SubTasksSection extends ConsumerWidget {
const Spacer(), const Spacer(),
TextButton.icon( TextButton.icon(
onPressed: onAdd, onPressed: onAdd,
icon: const Icon(Icons.add, size: 16), icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add'), label: const Text('Add'),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: colorScheme.primary, foregroundColor: colorScheme.primary,
+104 -8
View File
@@ -1,12 +1,17 @@
import 'dart:math' show min; import 'dart:math' show min;
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/message.dart'; import '../data/models/message.dart';
import '../providers/api_client_provider.dart';
import '../providers/settings_provider.dart';
import 'tool_call_chip.dart'; import 'tool_call_chip.dart';
class ChatMessageBubble extends StatelessWidget { class ChatMessageBubble extends ConsumerWidget {
final Message message; final Message message;
final String streamingStatus; final String streamingStatus;
const ChatMessageBubble({ const ChatMessageBubble({
@@ -16,9 +21,11 @@ class ChatMessageBubble extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final isUser = message.role == MessageRole.user; final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final serverUrl = ref.watch(serverUrlProvider) ?? '';
final dio = ref.watch(dioProvider);
final isGenerating = message.status == 'generating'; final isGenerating = message.status == 'generating';
final toolCalls = message.toolCalls ?? const []; final toolCalls = message.toolCalls ?? const [];
@@ -34,13 +41,16 @@ class ChatMessageBubble extends StatelessWidget {
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480), maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
), ),
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), 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 decoration: isUser
? BoxDecoration( ? BoxDecoration(
color: Colors.transparent, color: Colors.transparent,
border: Border.all( border: Border.all(color: scheme.outline, width: 1),
color: scheme.primary.withValues(alpha: 0.35),
width: 1,
),
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16), topLeft: Radius.circular(16),
topRight: Radius.circular(16), topRight: Radius.circular(16),
@@ -49,16 +59,28 @@ class ChatMessageBubble extends StatelessWidget {
), ),
) )
: BoxDecoration( : BoxDecoration(
color: scheme.surfaceContainerHighest, color: scheme.surface,
border: Border( border: Border(
left: BorderSide(color: scheme.primary, width: 2), left: BorderSide(color: scheme.primary, width: 2),
), ),
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4), topLeft: Radius.circular(16),
topRight: Radius.circular(16), topRight: Radius.circular(16),
bottomLeft: Radius.circular(4), bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16), 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( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -116,6 +138,14 @@ class ChatMessageBubble extends StatelessWidget {
if (message.content.isNotEmpty) if (message.content.isNotEmpty)
MarkdownBody( MarkdownBody(
data: message.content, data: message.content,
imageBuilder: (uri, title, alt) {
return _AuthImage(
uri: uri,
alt: alt,
serverUrl: serverUrl,
dio: dio,
);
},
styleSheet: MarkdownStyleSheet( styleSheet: MarkdownStyleSheet(
p: TextStyle( p: TextStyle(
color: isUser color: isUser
@@ -161,3 +191,69 @@ class ChatMessageBubble extends StatelessWidget {
); );
} }
} }
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:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart'; import '../data/models/message.dart';
class BriefingDigestCard extends StatefulWidget { class JournalPrepCard extends StatefulWidget {
/// The first assistant message from today's briefing, or null if none yet. /// 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; final Message? message;
/// Called when the user taps "Generate now". /// Called when the user taps "Generate now".
final VoidCallback? onGenerateNow; final VoidCallback? onGenerateNow;
const BriefingDigestCard({ const JournalPrepCard({
super.key, super.key,
required this.message, required this.message,
this.onGenerateNow, this.onGenerateNow,
}); });
@override @override
State<BriefingDigestCard> createState() => _BriefingDigestCardState(); State<JournalPrepCard> createState() => _JournalPrepCardState();
} }
class _BriefingDigestCardState extends State<BriefingDigestCard> { class _JournalPrepCardState extends State<JournalPrepCard> {
bool _expanded = false; bool _expanded = false;
@override @override
@@ -43,10 +45,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header row
Row( Row(
children: [ children: [
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary), Icon(LucideIcons.bookOpen, size: 18, color: scheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
_todayLabel(), _todayLabel(),
@@ -57,11 +58,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// Body
if (widget.message == null) ...[ if (widget.message == null) ...[
Text( Text(
'No briefing yet today.', 'No prep yet today.',
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
+90 -13
View File
@@ -1,18 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../data/local/database.dart';
import '../data/models/knowledge_item.dart'; import '../data/models/knowledge_item.dart';
import 'pending_sync_badge.dart';
class KnowledgeItemCard extends StatelessWidget { class KnowledgeItemCard extends StatelessWidget {
final KnowledgeItem item; final KnowledgeItem item;
const KnowledgeItemCard({super.key, required this.item}); const KnowledgeItemCard({super.key, required this.item});
String get _pendingDomain =>
item.noteType == 'task' ? kSyncDomainTasks : kSyncDomainNotes;
IconData get _icon => switch (item.noteType) { IconData get _icon => switch (item.noteType) {
'person' => Icons.person_outlined, 'person' => LucideIcons.user,
'place' => Icons.place_outlined, 'place' => LucideIcons.mapPin,
'list' => Icons.checklist_outlined, 'list' => LucideIcons.listChecks,
'task' => Icons.task_alt_outlined, 'task' => LucideIcons.checkCircle2,
_ => Icons.description_outlined, _ => LucideIcons.fileText,
}; };
Color _statusColor(BuildContext context) { Color _statusColor(BuildContext context) {
@@ -27,23 +33,42 @@ class KnowledgeItemCard extends StatelessWidget {
String? get _subtitle { String? get _subtitle {
if (item.noteType == 'task') { 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}'; if (item.dueDate != null) return 'Due ${item.dueDate}';
return item.status; return item.status;
} }
if (item.body.trim().isEmpty) return null; if (item.body.trim().isEmpty) return null;
final preview = item.body.trim().replaceAll('\n', ' '); final preview = item.body.trim().replaceAll('\n', ' ');
return preview.length > 120 ? '${preview.substring(0, 120)}' : preview; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
leading: Icon(_icon, color: _statusColor(context)), leading: Icon(_icon, color: _statusColor(context)),
title: Text( title: Row(
children: [
Flexible(
child: Text(
item.title.isEmpty ? '(untitled)' : item.title, item.title.isEmpty ? '(untitled)' : item.title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
),
PendingSyncBadge(domain: _pendingDomain, id: item.id),
],
),
subtitle: _subtitle != null subtitle: _subtitle != null
? Text( ? Text(
_subtitle!, _subtitle!,
@@ -53,13 +78,65 @@ class KnowledgeItemCard extends StatelessWidget {
) )
: null, : null,
trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null, trailing: item.tags.isNotEmpty ? _TagChips(tags: item.tags) : null,
onTap: () { onTap: () => _onTap(context),
if (item.noteType == 'task') { );
context.push('/tasks/${item.id}/edit');
} else {
context.push('/notes/${item.id}');
} }
},
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),
],
],
),
),
),
); );
} }
} }
-232
View File
@@ -1,232 +0,0 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/models/news_item.dart';
class RssItemMeta {
final int id;
final String title;
final String url;
final String source;
final String snippet;
final DateTime? publishedAt;
const RssItemMeta({
required this.id,
required this.title,
required this.url,
required this.source,
required this.snippet,
this.publishedAt,
});
factory RssItemMeta.fromJson(Map<String, dynamic> json) => RssItemMeta(
id: json['id'] as int,
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
source: json['source'] as String? ?? '',
snippet: json['snippet'] as String? ?? '',
publishedAt: json['published_at'] != null
? DateTime.tryParse(json['published_at'] as String)
: null,
);
factory RssItemMeta.fromNewsItem(NewsItem item) => RssItemMeta(
id: item.id,
title: item.title,
url: item.url,
source: item.source,
snippet: item.snippet,
publishedAt: item.publishedAt,
);
String get relativeDate {
if (publishedAt == null) return '';
final diff = DateTime.now().difference(publishedAt!);
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inHours < 48) return 'Yesterday';
return '${publishedAt!.month}/${publishedAt!.day}';
}
}
class NewsCard extends StatelessWidget {
final RssItemMeta item;
final String? reaction; // 'up' | 'down' | null
final void Function(int itemId, String reaction) onReaction;
final VoidCallback? onDiscuss;
const NewsCard({
super.key,
required this.item,
required this.reaction,
required this.onReaction,
this.onDiscuss,
});
Future<void> _openUrl() async {
if (item.url.isEmpty) return;
final uri = Uri.tryParse(item.url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: scheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Source + date row
Row(
children: [
if (item.source.isNotEmpty)
Text(
item.source.toUpperCase(),
style: textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
),
),
if (item.source.isNotEmpty && item.relativeDate.isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
item.relativeDate,
style: textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 4),
// Title — tappable if URL present. Text stays in onSurface for
// contrast; the primary-colored underline carries the "this is a
// link" signal without tanking readability.
GestureDetector(
onTap: item.url.isNotEmpty ? _openUrl : null,
child: Text(
item.title,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: scheme.onSurface,
height: 1.3,
decoration: item.url.isNotEmpty ? TextDecoration.underline : null,
decorationColor: scheme.primary.withValues(alpha: 0.7),
decorationThickness: 1.5,
),
),
),
// Snippet
if (item.snippet.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.snippet,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
height: 1.45,
),
),
],
const SizedBox(height: 8),
// Actions row: reactions + discuss
Row(
children: [
_ReactionButton(
emoji: '👍',
active: reaction == 'up',
onTap: () => onReaction(item.id, 'up'),
),
const SizedBox(width: 6),
_ReactionButton(
emoji: '👎',
active: reaction == 'down',
onTap: () => onReaction(item.id, 'down'),
),
if (onDiscuss != null) ...[
const Spacer(),
_DiscussButton(onTap: onDiscuss!),
],
],
),
],
),
),
);
}
}
class _DiscussButton extends StatelessWidget {
final VoidCallback onTap;
const _DiscussButton({required this.onTap});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
border: Border.all(color: scheme.primary.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'Discuss',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: scheme.primary,
),
),
),
);
}
}
class _ReactionButton extends StatelessWidget {
final String emoji;
final bool active;
final VoidCallback onTap;
const _ReactionButton({
required this.emoji,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: active ? scheme.primary.withValues(alpha: 0.12) : Colors.transparent,
border: Border.all(
color: active ? scheme.primary : scheme.outlineVariant,
),
borderRadius: BorderRadius.circular(6),
),
child: Text(emoji, style: const TextStyle(fontSize: 14)),
),
);
}
}
+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,
),
),
);
}
}
+14 -13
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../core/constants.dart'; import '../core/constants.dart';
@@ -45,24 +46,24 @@ const Map<String, String> _toolLabels = {
}; };
IconData _iconFor(String fn) { IconData _iconFor(String fn) {
if (fn.contains('note')) return Icons.sticky_note_2_outlined; if (fn.contains('note')) return LucideIcons.stickyNote;
if (fn.contains('task')) return Icons.check_circle_outline; if (fn.contains('task')) return LucideIcons.checkCircle2;
if (fn.contains('event') || fn.contains('calendar')) { if (fn.contains('event') || fn.contains('calendar')) {
return Icons.event_outlined; return LucideIcons.calendarCheck;
} }
if (fn.contains('project')) return Icons.folder_outlined; if (fn.contains('project')) return LucideIcons.folder;
if (fn.contains('milestone')) return Icons.flag_outlined; if (fn.contains('milestone')) return LucideIcons.flag;
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) { if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
return Icons.public; return LucideIcons.globe;
} }
if (fn.contains('image')) return Icons.image_outlined; if (fn.contains('image')) return LucideIcons.image;
if (fn.contains('person') || fn.contains('profile')) { if (fn.contains('person') || fn.contains('profile')) {
return Icons.person_outline; return LucideIcons.user;
} }
if (fn.contains('place')) return Icons.place_outlined; if (fn.contains('place')) return LucideIcons.mapPin;
if (fn.contains('rag') || fn.contains('scope')) return Icons.tune; if (fn.contains('rag') || fn.contains('scope')) return LucideIcons.sliders;
if (fn.contains('calculate')) return Icons.calculate_outlined; if (fn.contains('calculate')) return LucideIcons.calculator;
return Icons.auto_awesome; return LucideIcons.sparkles;
} }
/// Pull the destination route for this tool call from its `result` payload, /// Pull the destination route for this tool call from its `result` payload,
@@ -161,7 +162,7 @@ class ToolCallChip extends StatelessWidget {
), ),
if (route != null) ...[ if (route != null) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Icon(Icons.arrow_forward, size: 11, color: fg), Icon(LucideIcons.arrowRight, size: 11, color: fg),
], ],
], ],
), ),
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/voice_provider.dart'; import '../providers/voice_provider.dart';
@@ -48,8 +49,8 @@ class VoiceMicButton extends StatelessWidget {
color: iconColor, color: iconColor,
), ),
), ),
VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20), VoiceMode.playing => Icon(LucideIcons.volume2, color: iconColor, size: 20),
_ => Icon(Icons.mic, color: iconColor, size: 20), _ => Icon(LucideIcons.mic, color: iconColor, size: 20),
}; };
} }
-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(),
),
),
],
],
),
);
}
}
@@ -9,6 +9,7 @@
#include <flutter_timezone/flutter_timezone_plugin.h> #include <flutter_timezone/flutter_timezone_plugin.h>
#include <open_file_linux/open_file_linux_plugin.h> #include <open_file_linux/open_file_linux_plugin.h>
#include <record_linux/record_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> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
@@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) record_linux_registrar = g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar); 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 = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
+2
View File
@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_timezone flutter_timezone
open_file_linux open_file_linux
record_linux record_linux
sqlite3_flutter_libs
url_launcher_linux url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)
@@ -11,8 +11,9 @@ import flutter_timezone
import just_audio import just_audio
import open_file_mac import open_file_mac
import package_info_plus import package_info_plus
import record_darwin import record_macos
import shared_preferences_foundation import shared_preferences_foundation
import sqlite3_flutter_libs
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -22,7 +23,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }
+176 -8
View File
@@ -57,6 +57,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" 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: characters:
dependency: transitive dependency: transitive
description: description:
@@ -65,6 +113,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -153,6 +209,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" 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: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -177,6 +241,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" 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: equatable:
dependency: transitive dependency: transitive
description: description:
@@ -368,6 +448,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.2" version: "8.0.2"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -496,6 +584,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" 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: markdown:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -649,7 +745,7 @@ packages:
source: hosted source: hosted
version: "3.2.1" version: "3.2.1"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@@ -800,14 +896,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: record:
dependency: "direct main" dependency: "direct main"
description: description:
name: record name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6" sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.2.1" version: "6.2.0"
record_android: record_android:
dependency: transitive dependency: transitive
description: description:
@@ -816,22 +928,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.1" version: "1.5.1"
record_darwin: record_ios:
dependency: transitive dependency: transitive
description: description:
name: record_darwin name: record_ios
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" version: "1.2.0"
record_linux: record_linux:
dependency: "direct overridden" dependency: transitive
description: description:
name: record_linux name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" 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: record_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -973,6 +1093,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: source_map_stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -997,6 +1125,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -1021,6 +1173,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" 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: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -1165,6 +1325,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.3" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
+11 -3
View File
@@ -12,6 +12,7 @@ dependencies:
sdk: flutter sdk: flutter
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
lucide_icons: ^0.257.0
flutter_riverpod: ^3.3.1 flutter_riverpod: ^3.3.1
go_router: ^17.1.0 go_router: ^17.1.0
dio: ^5.6.0 dio: ^5.6.0
@@ -28,18 +29,25 @@ dependencies:
google_fonts: ^8.0.2 google_fonts: ^8.0.2
flutter_timezone: ^5.0.2 flutter_timezone: ^5.0.2
url_launcher: ^6.3.1 url_launcher: ^6.3.1
record: ^5.0.0 record: ^6.2.0
vad: ^0.0.7
just_audio: ^0.9.39 just_audio: ^0.9.39
table_calendar: ^3.1.2 table_calendar: ^3.1.2
dependency_overrides: # Tier 2 offline mode — local SQL store via Drift (sqlite3 under the hood).
record_linux: ^1.3.0 # 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: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.3 flutter_launcher_icons: ^0.14.3
drift_dev: ^2.20.0
build_runner: ^2.4.13
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
+29 -65
View File
@@ -1,10 +1,9 @@
import 'package:fabled_app/data/models/calendar_event.dart'; import 'package:fabled_app/data/models/calendar_event.dart';
import 'package:fabled_app/data/api/voice_api.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/providers/voice_provider.dart';
import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/knowledge_item.dart';
import 'package:fabled_app/data/models/note.dart'; import 'package:fabled_app/data/models/note.dart';
import 'package:fabled_app/data/models/news_item.dart';
import 'package:fabled_app/data/models/briefing_feed.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
@@ -150,47 +149,6 @@ void main() {
}); });
}); });
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('CalendarEvent.fromJson', () { group('CalendarEvent.fromJson', () {
test('parses all fields', () { test('parses all fields', () {
final json = { final json = {
@@ -250,30 +208,36 @@ void main() {
}); });
}); });
group('BriefingFeed.fromJson', () { group('QueueFailure.message', () {
test('parses all fields', () { test('overwritten with title quotes the title', () {
final json = { final f = QueueFailure(
'id': 7, reason: QueueFailureReason.overwritten,
'title': 'Hacker News', domain: 'notes',
'url': 'https://news.ycombinator.com/rss', title: 'Grocery list',
'category': 'tech', );
}; expect(f.message, contains('"Grocery list"'));
final feed = BriefingFeed.fromJson(json); expect(f.message, contains('overwritten'));
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', () { test('rejected without title falls back to generic phrasing', () {
final json = { final f = QueueFailure(
'id': 8, reason: QueueFailureReason.rejected,
'title': 'Feed', domain: 'tasks',
'url': 'https://example.com/rss', detail: 'title required',
'category': null, );
}; expect(f.message, contains('an offline edit'));
final feed = BriefingFeed.fromJson(json); expect(f.message, contains('title required'));
expect(feed.category, isNull); });
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'));
}); });
}); });
} }
@@ -10,6 +10,7 @@
#include <flutter_timezone/flutter_timezone_plugin_c_api.h> #include <flutter_timezone/flutter_timezone_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.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> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar( RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }
+2
View File
@@ -7,10 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_timezone flutter_timezone
permission_handler_windows permission_handler_windows
record_windows record_windows
sqlite3_flutter_libs
url_launcher_windows url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)