Compare commits

...

21 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
67 changed files with 10442 additions and 2061 deletions
+119 -143
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -6,20 +7,20 @@ import 'package:flutter_timezone/flutter_timezone.dart';
import 'core/constants.dart';
import 'core/theme.dart';
import 'data/repositories/write_queue.dart';
import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart';
import 'core/exceptions.dart';
import 'providers/capture_queue_provider.dart';
import 'providers/capture_work_queue_provider.dart';
import 'providers/briefing_provider.dart';
import 'providers/calendar_provider.dart';
import 'providers/chat_provider.dart';
import 'providers/journal_provider.dart';
import 'providers/knowledge_provider.dart';
import 'providers/news_provider.dart';
import 'providers/settings_provider.dart';
import 'providers/update_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/briefing/briefing_screen.dart';
import 'screens/journal/journal_screen.dart';
import 'screens/knowledge/knowledge_screen.dart';
import 'screens/library/project_tasks_screen.dart';
import 'screens/chat/chat_screen.dart';
@@ -29,12 +30,12 @@ import 'screens/projects/project_edit_screen.dart';
import 'screens/projects/projects_screen.dart';
import 'screens/notes/note_edit_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'screens/news/news_screen.dart';
import 'screens/setup/setup_screen.dart';
import 'screens/splash/splash_screen.dart';
import 'screens/tasks/task_edit_screen.dart';
import 'screens/calendar/calendar_screen.dart';
import 'providers/voice_provider.dart';
import 'widgets/offline_banner.dart';
import 'widgets/voice_mic_button.dart';
// ChangeNotifier that fires when auth or server URL changes,
@@ -44,6 +45,7 @@ class _RouterNotifier extends ChangeNotifier {
_RouterNotifier(Ref ref) {
ref.listen(authProvider, (_, _) => notifyListeners());
ref.listen(serverUrlProvider, (_, _) => notifyListeners());
ref.listen(hasEverLoggedInProvider, (_, _) => notifyListeners());
}
}
@@ -57,6 +59,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final location = state.matchedLocation;
final serverUrl = ref.read(serverUrlProvider);
final authStatus = ref.read(authProvider);
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
if (serverUrl == null || serverUrl.isEmpty) {
if (location != Routes.setup) return Routes.setup;
@@ -70,6 +73,16 @@ final routerProvider = Provider<GoRouter>((ref) {
return null;
}
// Offline and never logged in on this device — can't prove identity,
// so gate behind login. Offline + ever-logged-in falls through to
// normal navigation with the offline banner surfacing in _Shell.
if (authStatus == AuthStatus.offline && !hasEverLoggedIn) {
if (location != Routes.login && location != Routes.setup) {
return Routes.login;
}
return null;
}
return null;
},
routes: [
@@ -148,8 +161,8 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state, child) => _Shell(child: child),
routes: [
GoRoute(
path: Routes.briefing,
builder: (_, _) => const BriefingScreen(),
path: Routes.journal,
builder: (_, _) => const JournalScreen(),
),
GoRoute(
path: Routes.knowledge,
@@ -163,10 +176,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.projects,
builder: (_, _) => const ProjectsScreen(),
),
GoRoute(
path: Routes.news,
builder: (_, _) => const NewsScreen(),
),
GoRoute(
path: Routes.calendar,
builder: (_, _) => const CalendarScreen(),
@@ -186,15 +195,18 @@ class _Shell extends ConsumerStatefulWidget {
}
class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
static const _tabs = [
Routes.briefing,
static const _baseTabs = [
Routes.journal,
Routes.knowledge,
Routes.conversations,
Routes.projects,
Routes.news,
Routes.calendar,
];
List<String> _tabs() => [
..._baseTabs,
Routes.calendar,
];
// Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh;
@@ -205,6 +217,8 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clean up any leftover APKs from previous update cycles.
ref.read(updateProvider.notifier).cleanup();
// Silent update check — only if we haven't already checked this session.
final repoUrl = ref.read(forgejoRepoUrlProvider);
if (repoUrl != null && repoUrl.isNotEmpty) {
@@ -213,7 +227,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
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();
});
}
@@ -241,27 +255,22 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
void _refreshAll() {
ref.read(conversationsProvider.notifier).refresh();
ref.read(calendarProvider.notifier).refresh();
ref.read(newsProvider.notifier).refresh();
ref.read(knowledgeProvider.notifier).refresh();
// briefingProvider is an AsyncNotifier family; invalidating is safe
// even if no conversation is open — it doesn't cause flicker since
// the briefing screen isn't a list view.
ref.invalidate(briefingProvider);
// journalProvider is an AsyncNotifier; invalidating is safe even if
// the journal screen isn't currently mounted.
ref.invalidate(journalProvider);
}
/// Refresh only the provider backing the given shell tab index.
void _refreshTab(int index) {
switch (index) {
case 0:
ref.invalidate(briefingProvider);
case 1:
ref.read(knowledgeProvider.notifier).refresh();
case 2:
ref.read(conversationsProvider.notifier).refresh();
case 4:
ref.read(newsProvider.notifier).refresh();
case 5:
ref.read(calendarProvider.notifier).refresh();
/// Refresh only the provider backing the given shell tab route.
void _refreshTab(String route) {
if (route == Routes.journal) {
ref.invalidate(journalProvider);
} else if (route == Routes.knowledge) {
ref.read(knowledgeProvider.notifier).refresh();
} else if (route == Routes.conversations) {
ref.read(conversationsProvider.notifier).refresh();
} else if (route == Routes.calendar) {
ref.read(calendarProvider.notifier).refresh();
}
}
@@ -274,9 +283,9 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
}
}
int _tabIndex(String location) {
for (var i = 0; i < _tabs.length; i++) {
if (location.startsWith(_tabs[i])) return i;
int _tabIndex(String location, List<String> tabs) {
for (var i = 0; i < tabs.length; i++) {
if (location.startsWith(tabs[i])) return i;
}
return 0;
}
@@ -289,7 +298,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
leading: const Icon(LucideIcons.folder),
title: const Text('Projects'),
onTap: () {
Navigator.pop(context);
@@ -297,15 +306,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
},
),
ListTile(
leading: const Icon(Icons.newspaper_outlined),
title: const Text('News'),
onTap: () {
Navigator.pop(context);
context.push(Routes.news);
},
),
ListTile(
leading: const Icon(Icons.calendar_month_outlined),
leading: const Icon(LucideIcons.calendar),
title: const Text('Calendar'),
onTap: () {
Navigator.pop(context);
@@ -318,66 +319,26 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
);
}
void _showUpdateDialog(UpdateState update) {
showDialog<void>(
context: context,
builder: (dialogContext) => Consumer(
builder: (context, ref, _) {
final state = ref.watch(updateProvider);
final isDownloading = state.status == UpdateStatus.downloading;
return AlertDialog(
title: const Text('Update available'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
if (state.currentVersion != null)
Text(
'Installed: v${state.currentVersion}',
style: Theme.of(context).textTheme.bodySmall,
),
if (isDownloading) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: state.downloadProgress > 0
? state.downloadProgress
: null,
),
const SizedBox(height: 4),
Text(
'Downloading… '
'${(state.downloadProgress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
if (state.status == UpdateStatus.error &&
state.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
state.errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
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 _showUpdateSnackbar(UpdateState update) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('v${update.latestVersion} ready to install'),
duration: const Duration(seconds: 6),
action: SnackBarAction(
label: 'Install',
onPressed: () => ref.read(updateProvider.notifier).install(),
),
),
);
}
void _showQueueFailureSnackbar(QueueFailure failure) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(failure.message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 6),
),
);
}
@@ -386,18 +347,37 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
Widget build(BuildContext context) {
// Show update dialog once when a new version is detected.
ref.listen(updateProvider, (prev, next) {
if (next.status == UpdateStatus.available &&
prev?.status != UpdateStatus.available) {
if (next.status == UpdateStatus.readyToInstall &&
prev?.status != UpdateStatus.readyToInstall) {
WidgetsBinding.instance
.addPostFrameCallback((_) => _showUpdateDialog(next));
.addPostFrameCallback((_) => _showUpdateSnackbar(next));
}
});
// Phase 3 — drain the offline write queue when we transition into
// an online state. Fires for unknown→authenticated (cold start),
// offline→authenticated (came back), and unauthenticated→authenticated
// (login). Idempotent if the queue is empty.
ref.listen(authProvider, (prev, next) {
if (next == AuthStatus.authenticated &&
prev != AuthStatus.authenticated) {
ref.read(writeQueueProvider).replay();
}
});
// Phase 3 — surface queue failures (overwrites, missing targets, 4xx
// rejections) so the user knows their offline edit didn't land.
ref.listen(writeQueueFailuresProvider, (_, next) {
next.whenData(_showQueueFailureSnackbar);
});
final tabs = _tabs();
final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location);
final index = _tabIndex(location, tabs);
// Refresh the incoming tab's data when switching between shell tabs.
if (_prevTabIndex != null && _prevTabIndex != index) {
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(index));
final route = index < tabs.length ? tabs[index] : tabs[0];
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshTab(route));
}
_prevTabIndex = index;
@@ -409,43 +389,39 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
body: SafeArea(
child: Column(
children: [
const OfflineBanner(),
const _QuickCaptureBar(),
Expanded(
child: Row(
children: [
NavigationRail(
selectedIndex: index,
onDestinationSelected: (i) => context.go(_tabs[i]),
onDestinationSelected: (i) => context.go(tabs[i]),
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: Text('Briefing'),
icon: Icon(LucideIcons.bookOpen),
selectedIcon: Icon(LucideIcons.bookOpen),
label: Text('Journal'),
),
NavigationRailDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
icon: Icon(LucideIcons.lightbulb),
selectedIcon: Icon(LucideIcons.lightbulb),
label: Text('Knowledge'),
),
NavigationRailDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
icon: Icon(LucideIcons.messageCircle),
selectedIcon: Icon(LucideIcons.messageCircle),
label: Text('Chat'),
),
NavigationRailDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
icon: Icon(LucideIcons.folder),
selectedIcon: Icon(LucideIcons.folder),
label: Text('Projects'),
),
NavigationRailDestination(
icon: Icon(Icons.newspaper_outlined),
selectedIcon: Icon(Icons.newspaper),
label: Text('News'),
),
NavigationRailDestination(
icon: Icon(Icons.calendar_month_outlined),
selectedIcon: Icon(Icons.calendar_month),
icon: Icon(LucideIcons.calendar),
selectedIcon: Icon(LucideIcons.calendar),
label: Text('Calendar'),
),
],
@@ -464,6 +440,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
return Scaffold(
body: Column(
children: [
const OfflineBanner(),
const _QuickCaptureBar(),
Expanded(
child: MediaQuery.removePadding(
@@ -480,28 +457,28 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
if (i == 3) {
_showMoreSheet(context);
} else {
context.go(_tabs[i]);
context.go(tabs[i]);
}
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.wb_sunny_outlined),
selectedIcon: Icon(Icons.wb_sunny),
label: 'Briefing',
icon: Icon(LucideIcons.bookOpen),
selectedIcon: Icon(LucideIcons.bookOpen),
label: 'Journal',
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
icon: Icon(LucideIcons.lightbulb),
selectedIcon: Icon(LucideIcons.lightbulb),
label: 'Knowledge',
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
icon: Icon(LucideIcons.messageCircle),
selectedIcon: Icon(LucideIcons.messageCircle),
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.more_horiz_outlined),
selectedIcon: Icon(Icons.more_horiz),
icon: Icon(LucideIcons.moreHorizontal),
selectedIcon: Icon(LucideIcons.moreHorizontal),
label: 'More',
),
],
@@ -529,7 +506,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
@override
void dispose() {
_controller.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -633,7 +609,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
prefixIcon: totalPending > 0
? Badge(
label: Text('$totalPending'),
child: const Icon(Icons.cloud_upload_outlined),
child: const Icon(LucideIcons.uploadCloud),
)
: isWorking
? const Padding(
@@ -645,10 +621,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
strokeWidth: 2),
),
)
: const Icon(Icons.auto_awesome_outlined),
: const Icon(LucideIcons.sparkles),
suffixIcon: _controller.text.trim().isNotEmpty
? IconButton(
icon: const Icon(Icons.send),
icon: const Icon(LucideIcons.send),
onPressed: _submit,
tooltip: 'Capture',
)
@@ -663,7 +639,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
onTap: _toggleCaptureMic,
),
IconButton(
icon: const Icon(Icons.settings_outlined),
icon: const Icon(LucideIcons.settings),
tooltip: 'Settings',
onPressed: () => context.push(Routes.settings),
),
+1 -2
View File
@@ -16,8 +16,7 @@ abstract class Routes {
static const chat = '/chat/:id';
static const quickCapture = '/quick-capture';
static const settings = '/settings';
static const briefing = '/briefing';
static const news = '/news';
static const journal = '/journal';
static const calendar = '/calendar';
static const projectTasks = '/projects/:id/tasks';
}
+9
View File
@@ -17,3 +17,12 @@ class AuthException extends AppException {
class NotFoundException extends AppException {
const NotFoundException(super.message);
}
/// 4xx/5xx response received from the server (excluding 401/404 which have
/// dedicated subclasses). Distinguished from `NetworkException` (connection
/// failure) so the offline write queue can drop non-retryable failures
/// instead of looping forever on a 422.
class ServerException extends AppException {
final int statusCode;
const ServerException(super.message, this.statusCode);
}
+149 -35
View File
@@ -2,35 +2,142 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
// ── Colour constants ──────────────────────────────────────────────────────────
//
// Mirrors the web frontend's design system (`docs/design-system.md` in
// fabledscribe). Foundation pass shipped on web 2026-04-27 in `7a9a8b7`;
// this is the equivalent palette swap for Flutter. Per-screen "surface
// phase" reclassification (button Hybrid rule, border audit, etc.) is
// deferred — most widgets read `colorScheme.primary` so the palette flip
// alone covers a large surface.
const _darkBackground = Color(0xFF0F0F14);
const _darkSurface = Color(0xFF16161F);
const _darkSurfaceVar = Color(0xFF1A1A24);
const _darkPrimary = Color(0xFF7C3AED);
const _darkOnSurface = Color(0xFFE4E4F0);
const _darkOnSurfaceVar = Color(0xFF8888A8);
const _darkOutline = Color(0xFF2A2A3A);
// Dark mode — Obsidian / Iron / Pewter / Parchment (FabledSword baseline)
const _darkBackground = Color(0xFF14171A); // Obsidian
const _darkSurface = Color(0xFF1E2228); // Iron
const _darkSurfaceVar = Color(0xFF2C313A); // Slate
const _darkPrimary = Color(0xFF5B4A8A); // dusty violet (Scribe accent)
const _darkPrimaryDeep = Color(0xFF3F3560); // gradient stop
const _darkOnSurface = Color(0xFFE8E4D8); // Parchment
const _darkOnSurfaceVar = Color(0xFFC2BFB4); // Vellum
const _darkOutline = Color(0xFF3F4651); // Pewter
const _lightBackground = Color(0xFFF5F5FB);
const _lightSurface = Color(0xFFFFFFFF);
const _lightSurfaceVar = Color(0xFFF0F0F8);
const _lightPrimary = Color(0xFF7C3AED);
const _lightOnSurface = Color(0xFF1A1A1A);
const _lightOnSurfaceVar = Color(0xFF666666);
const _lightOutline = Color(0xFFDDDDE8);
// Light mode — warm parchment (Scribe iteration)
const _lightBackground = Color(0xFFF5F1E8); // warm cream page
const _lightSurface = Color(0xFFFBF8F0); // near-white card
const _lightSurfaceVar = Color(0xFFEFEAE0); // inset / hover surface
const _lightPrimary = Color(0xFF5B4A8A); // dusty violet (same on both modes)
const _lightOnSurface = Color(0xFF14171A); // deep ink (Obsidian inverted)
const _lightOnSurfaceVar = Color(0xFF5A5852); // warm mid grey
const _lightOutline = Color(0xFFD9D6CE); // warm light pewter
// Semantic — identical across themes
const _semanticError = Color(0xFFC04A1F); // terracotta — validation/error
const _semanticErrorBg = Color(0xFF7E2A1F); // Oxblood-hover for dark error container
const _semanticErrorFg = Color(0xFFFEE2E2); // light text on dark error container
// Action tokens — Hybrid rule per the doc. These don't fit ColorScheme
// natively (Material's primary/secondary/tertiary slots all carry the
// brand accent), so they live on a ThemeExtension exposed below.
const _actionPrimary = Color(0xFF4A5D3F); // Moss
const _actionPrimaryHover = Color(0xFF5A6F4D);
const _actionSecondary = Color(0xFF8B7355); // Bronze
const _actionSecondaryHover = Color(0xFFA0876A);
const _actionDestructive = Color(0xFF6B2118); // Oxblood
const _actionDestructiveHover = Color(0xFF7E2A1F);
const _actionGhostBorder = Color(0xFF3F4651); // Pewter (same as outline)
// ── Action ThemeExtension ─────────────────────────────────────────────────────
// Read with: Theme.of(context).extension<ActionColors>()!.primary
//
// Flutter's ColorScheme has primary/secondary/tertiary all conceptually
// "branded", whereas the doc's Hybrid rule reserves accent for brand
// moments (Send, empty-state CTAs) and routes action buttons through a
// separate Moss/Bronze/Oxblood/Pewter palette. This extension carries
// those tokens without polluting ColorScheme.
@immutable
class ActionColors extends ThemeExtension<ActionColors> {
final Color primary; // Moss — Save / Confirm
final Color primaryHover;
final Color secondary; // Bronze — Cancel / alternate paths
final Color secondaryHover;
final Color destructive; // Oxblood — Delete / irreversible
final Color destructiveHover;
final Color ghostBorder; // Pewter — tertiary / "later" / "skip"
const ActionColors({
required this.primary,
required this.primaryHover,
required this.secondary,
required this.secondaryHover,
required this.destructive,
required this.destructiveHover,
required this.ghostBorder,
});
static const _kStandard = ActionColors(
primary: _actionPrimary,
primaryHover: _actionPrimaryHover,
secondary: _actionSecondary,
secondaryHover: _actionSecondaryHover,
destructive: _actionDestructive,
destructiveHover: _actionDestructiveHover,
ghostBorder: _actionGhostBorder,
);
@override
ActionColors copyWith({
Color? primary,
Color? primaryHover,
Color? secondary,
Color? secondaryHover,
Color? destructive,
Color? destructiveHover,
Color? ghostBorder,
}) {
return ActionColors(
primary: primary ?? this.primary,
primaryHover: primaryHover ?? this.primaryHover,
secondary: secondary ?? this.secondary,
secondaryHover: secondaryHover ?? this.secondaryHover,
destructive: destructive ?? this.destructive,
destructiveHover: destructiveHover ?? this.destructiveHover,
ghostBorder: ghostBorder ?? this.ghostBorder,
);
}
@override
ActionColors lerp(ThemeExtension<ActionColors>? other, double t) {
if (other is! ActionColors) return this;
return ActionColors(
primary: Color.lerp(primary, other.primary, t)!,
primaryHover: Color.lerp(primaryHover, other.primaryHover, t)!,
secondary: Color.lerp(secondary, other.secondary, t)!,
secondaryHover: Color.lerp(secondaryHover, other.secondaryHover, t)!,
destructive: Color.lerp(destructive, other.destructive, t)!,
destructiveHover: Color.lerp(destructiveHover, other.destructiveHover, t)!,
ghostBorder: Color.lerp(ghostBorder, other.ghostBorder, t)!,
);
}
}
// ── Typography ─────────────────────────────────────────────────────────────────
// Inter for body / labels / titleMedium-and-below.
// Fraunces for display / headline / titleLarge — only at ≥18px per the doc.
// JetBrains Mono available via GoogleFonts.jetBrainsMono() at call sites for
// code blocks (Flutter's TextTheme has no dedicated mono slot).
TextTheme _buildTextTheme(TextTheme base) {
final inter = GoogleFonts.interTextTheme(base);
final fraunces = GoogleFonts.frauncesTextTheme(base);
return base.copyWith(
// Headings / titles use Fraunces
return inter.copyWith(
displayLarge: fraunces.displayLarge,
displayMedium: fraunces.displayMedium,
displaySmall: fraunces.displaySmall,
headlineLarge: fraunces.headlineLarge,
headlineMedium: fraunces.headlineMedium,
headlineSmall: fraunces.headlineSmall,
titleLarge: fraunces.titleLarge,
titleMedium: fraunces.titleMedium,
// Body / labels remain system default
// titleMedium / titleSmall / body* / label* stay Inter
);
}
@@ -41,7 +148,7 @@ ThemeData fabledDarkTheme() {
brightness: Brightness.dark,
primary: _darkPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(0xFF5B21B6),
primaryContainer: _darkPrimaryDeep,
onPrimaryContainer: _darkOnSurface,
secondary: _darkPrimary,
onSecondary: Colors.white,
@@ -51,10 +158,10 @@ ThemeData fabledDarkTheme() {
onTertiary: Colors.white,
tertiaryContainer: _darkSurfaceVar,
onTertiaryContainer: _darkOnSurface,
error: const Color(0xFFEF4444),
error: _semanticError,
onError: Colors.white,
errorContainer: const Color(0xFF7F1D1D),
onErrorContainer: const Color(0xFFFEE2E2),
errorContainer: _semanticErrorBg,
onErrorContainer: _semanticErrorFg,
surface: _darkSurface,
onSurface: _darkOnSurface,
surfaceContainerHighest: _darkSurfaceVar,
@@ -73,6 +180,7 @@ ThemeData fabledDarkTheme() {
colorScheme: cs,
scaffoldBackgroundColor: _darkBackground,
textTheme: _buildTextTheme(ThemeData.dark().textTheme),
extensions: const [ActionColors._kStandard],
cardTheme: CardThemeData(
color: _darkSurface,
elevation: 2,
@@ -91,15 +199,15 @@ ThemeData fabledDarkTheme() {
filled: true,
fillColor: _darkSurfaceVar,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkOutline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkOutline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _darkPrimary, width: 2),
),
),
@@ -118,7 +226,9 @@ ThemeData fabledLightTheme() {
brightness: Brightness.light,
primary: _lightPrimary,
onPrimary: Colors.white,
primaryContainer: const Color(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,
secondary: _lightPrimary,
onSecondary: Colors.white,
@@ -128,10 +238,10 @@ ThemeData fabledLightTheme() {
onTertiary: Colors.white,
tertiaryContainer: _lightSurfaceVar,
onTertiaryContainer: _lightOnSurface,
error: const Color(0xFFDC2626),
error: _semanticError,
onError: Colors.white,
errorContainer: const Color(0xFFFEE2E2),
onErrorContainer: const Color(0xFF7F1D1D),
onErrorContainer: _semanticErrorBg,
surface: _lightSurface,
onSurface: _lightOnSurface,
surfaceContainerHighest: _lightSurfaceVar,
@@ -150,6 +260,7 @@ ThemeData fabledLightTheme() {
colorScheme: cs,
scaffoldBackgroundColor: _lightBackground,
textTheme: _buildTextTheme(ThemeData.light().textTheme),
extensions: const [ActionColors._kStandard],
cardTheme: CardThemeData(
color: _lightSurface,
elevation: 1,
@@ -168,15 +279,15 @@ ThemeData fabledLightTheme() {
filled: true,
fillColor: _lightSurfaceVar,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightOutline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightOutline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _lightPrimary, width: 2),
),
),
@@ -191,7 +302,10 @@ ThemeData fabledLightTheme() {
}
// ── GradientButton ─────────────────────────────────────────────────────────────
// Use wherever the web app uses the indigo gradient button (send, primary actions).
// Brand-moment CTA equivalent of the web's `--gradient-cta` — chat send,
// journal send, primary "Scribe-feature" actions. Reserve for those moments
// per the doc's Hybrid rule; use FilledButton with ActionColors.primary
// (Moss) for everything else.
class GradientButton extends StatelessWidget {
final VoidCallback? onPressed;
@@ -218,15 +332,15 @@ class GradientButton extends StatelessWidget {
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
colors: [_darkPrimary, _darkPrimaryDeep],
),
color: disabled ? const Color(0xFF7C3AED) : null,
color: disabled ? _darkPrimary : null,
borderRadius: BorderRadius.circular(12),
boxShadow: disabled
? null
: [
BoxShadow(
color: const Color(0xFF7C3AED).withValues(alpha: 0.45),
color: _darkPrimary.withValues(alpha: 0.45),
blurRadius: 8,
offset: const Offset(0, 3),
),
+16
View File
@@ -63,5 +63,21 @@ AppException dioToApp(DioException e) {
final status = e.response?.statusCode;
if (status == 401) return const AuthException('Not authenticated.');
if (status == 404) return const NotFoundException('Resource not found.');
if (status != null && status >= 400) {
final msg = _extractServerErrorMessage(e.response) ??
'Request failed (HTTP $status).';
return ServerException(msg, status);
}
return NetworkException(e.message ?? 'Unknown network error.');
}
String? _extractServerErrorMessage(Response? resp) {
final data = resp?.data;
if (data is Map<String, dynamic>) {
for (final key in const ['detail', 'error', 'message']) {
final value = data[key];
if (value is String && value.isNotEmpty) return value;
}
}
return null;
}
-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},
);
}
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
/// to Whisper, reducing mishearings of domain-specific words.
/// Returns empty string on empty or error response.
@@ -49,8 +49,8 @@ class VoiceApi {
final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes(
audioBytes,
filename: 'audio.m4a',
contentType: DioMediaType('audio', 'mp4'),
filename: 'audio.wav',
contentType: DioMediaType('audio', 'wav'),
),
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,
);
}
-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';
export '../api/chat_api.dart'
show ChatStreamEvent, ChatTextChunk, ChatStatusUpdate, ChatToolCall;
import '../local/database.dart';
import '../models/conversation.dart';
import '../models/message.dart';
/// Chat repository with read-through caching for the conversation list only.
/// Messages and the live SSE stream are intentionally not cached — they are
/// per-conversation, large, and require a live network anyway.
class ChatRepository {
final ChatApi _api;
const ChatRepository(this._api);
final FabledDatabase _db;
const ChatRepository(this._api, this._db);
Future<List<Conversation>> getConversations() async {
try {
final conversations = await _api.getConversations();
await _db.replaceAllConversations(conversations);
return conversations;
} on NetworkException {
final cached = await _db.getAllConversations();
if (cached.isEmpty) rethrow;
return cached;
}
}
Future<List<Conversation>> getConversations() => _api.getConversations();
Future<Conversation> createConversation(String title) =>
_api.createConversation(title);
Future<void> deleteConversation(int id) => _api.deleteConversation(id);
Future<void> deleteConversation(int id) async {
await _api.deleteConversation(id);
await _db.deleteConversation(id);
}
Future<(Conversation, List<Message>)> getMessages(int conversationId) =>
_api.getMessages(conversationId);
Future<void> sendMessage(int conversationId, String content) =>
_api.sendMessage(conversationId, content);
Stream<ChatStreamEvent> streamGeneration(int conversationId) =>
_api.streamGeneration(conversationId);
}
@@ -0,0 +1,144 @@
import '../../core/exceptions.dart';
import '../api/events_api.dart';
import '../local/database.dart';
import '../models/calendar_event.dart';
/// Calendar events repository with read-through caching (Phase 1+2) and
/// offline-write queueing (Phase 3). Range-scoped reads: `getEvents(from, to)`
/// mirrors that window into the cache (events outside the window are left
/// alone) so successive disjoint range fetches don't clobber each other.
///
/// CalendarEvent has no `updatedAt` field, so write replay uses last-
/// writer-wins on update/delete (no server-side baseline check).
class EventsRepository {
final EventsApi _api;
final FabledDatabase _db;
const EventsRepository(this._api, this._db);
Future<List<CalendarEvent>> getEvents(DateTime from, DateTime to) async {
try {
final events = await _api.getEvents(from, to);
await _db.replaceEventsInRange(from, to, events);
return events;
} on NetworkException {
final cached = await _db.getEventsInRange(from, to);
if (cached.isEmpty) rethrow;
return cached;
}
}
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
try {
final event = await _api.createEvent(payload);
await _db.upsertEvent(event);
return event;
} on NetworkException {
final tempId = await _db.nextTempId();
final optimistic = _eventFromPayload(tempId, payload);
await _db.upsertEvent(optimistic);
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbCreate,
tempId: tempId,
payload: payload,
);
return optimistic;
}
}
Future<CalendarEvent> updateEvent(
int id, Map<String, dynamic> fields) async {
try {
final updated = await _api.updateEvent(id, fields);
await _db.upsertEvent(updated);
return updated;
} on NetworkException {
final cached = (await _db.getEventsInRange(
DateTime.fromMicrosecondsSinceEpoch(0),
DateTime.now().add(const Duration(days: 365 * 100)),
))
.where((e) => e.id == id)
.firstOrNull;
if (cached == null) rethrow;
final optimistic = _applyEventFields(cached, fields);
await _db.upsertEvent(optimistic);
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, fields);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbUpdate,
targetId: id,
payload: fields,
);
return optimistic;
}
}
Future<void> deleteEvent(int id) async {
try {
await _api.deleteEvent(id);
await _db.deleteEvent(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteEvent(id);
return;
}
await _db.deleteEvent(id);
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbDelete,
targetId: id,
);
}
}
}
CalendarEvent _eventFromPayload(int id, Map<String, dynamic> p) {
return CalendarEvent(
id: id,
title: p['title'] as String? ?? '',
startDt: _parseIso(p['start_dt'])!,
endDt: _parseIso(p['end_dt']),
allDay: p['all_day'] as bool? ?? false,
description: p['description'] as String? ?? '',
location: p['location'] as String? ?? '',
color: p['color'] as String? ?? '',
recurrence: p['recurrence'] as String?,
projectId: p['project_id'] as int?,
reminderMinutes: p['reminder_minutes'] as int?,
);
}
CalendarEvent _applyEventFields(CalendarEvent e, Map<String, dynamic> f) {
return CalendarEvent(
id: e.id,
title: f['title'] as String? ?? e.title,
startDt: _parseIso(f['start_dt']) ?? e.startDt,
endDt: f.containsKey('end_dt') ? _parseIso(f['end_dt']) : e.endDt,
allDay: f['all_day'] as bool? ?? e.allDay,
description: f['description'] as String? ?? e.description,
location: f['location'] as String? ?? e.location,
color: f['color'] as String? ?? e.color,
recurrence:
f.containsKey('recurrence') ? f['recurrence'] as String? : e.recurrence,
projectId: f.containsKey('project_id')
? f['project_id'] as int?
: e.projectId,
reminderMinutes: f.containsKey('reminder_minutes')
? f['reminder_minutes'] as int?
: e.reminderMinutes,
);
}
DateTime? _parseIso(dynamic raw) {
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw)?.toLocal();
return null;
}
+148 -10
View File
@@ -1,26 +1,164 @@
import '../../core/exceptions.dart';
import '../api/milestones_api.dart';
import '../local/database.dart';
import '../models/milestone.dart';
/// Milestones repository with read-through caching (Phase 1+2) and
/// offline-write queueing (Phase 3). See `notes_repository.dart` for the
/// full pattern.
class MilestonesRepository {
final MilestonesApi _api;
const MilestonesRepository(this._api);
final FabledDatabase _db;
Future<List<Milestone>> getAll(int projectId, {String? status}) =>
_api.getAll(projectId, status: status);
const MilestonesRepository(this._api, this._db);
Future<List<Milestone>> getAll(int projectId, {String? status}) async {
try {
final milestones = await _api.getAll(projectId, status: status);
// Only mirror the full per-project list to cache on the unfiltered
// fetch; a status filter would otherwise drop entries from cache.
if (status == null) {
await _db.replaceMilestonesForProject(projectId, milestones);
} else {
for (final m in milestones) {
await _db.upsertMilestone(m);
}
}
return milestones;
} on NetworkException {
final cached = await _db.getMilestonesForProject(projectId);
if (cached.isEmpty) rethrow;
if (status != null) {
return cached.where((m) => m.status == status).toList();
}
return cached;
}
}
Future<Milestone> create(
int projectId, {
required String title,
String? description,
int orderIndex = 0,
}) =>
_api.create(projectId,
title: title, description: description, orderIndex: orderIndex);
}) async {
try {
final milestone = await _api.create(
projectId,
title: title,
description: description,
orderIndex: orderIndex,
);
await _db.upsertMilestone(milestone);
return milestone;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Milestone(
id: tempId,
projectId: projectId,
title: title,
description: description,
status: 'active',
orderIndex: orderIndex,
total: 0,
completed: 0,
pct: 0.0,
createdAt: now,
updatedAt: now,
);
await _db.upsertMilestone(optimistic);
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'project_id': projectId,
'title': title,
'description': description,
'order_index': orderIndex,
},
);
return optimistic;
}
}
Future<Milestone> update(
int projectId, int milestoneId, Map<String, dynamic> fields) =>
_api.update(projectId, milestoneId, fields);
int projectId,
int milestoneId,
Map<String, dynamic> fields,
) async {
try {
final updated = await _api.update(projectId, milestoneId, fields);
await _db.upsertMilestone(updated);
return updated;
} on NetworkException {
final cached = (await _db.getMilestonesForProject(projectId))
.where((m) => m.id == milestoneId)
.firstOrNull;
if (cached == null) rethrow;
final optimistic = _applyMilestoneFields(cached, fields);
await _db.upsertMilestone(optimistic);
if (milestoneId < 0) {
final queued =
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
if (queued != null) {
// Carry project_id through coalesced payload — replay needs it.
final merged = <String, dynamic>{
'project_id': projectId,
...fields,
};
await _db.updatePendingPayload(queued.id, merged);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbUpdate,
targetId: milestoneId,
payload: <String, dynamic>{'project_id': projectId, ...fields},
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
}
Future<void> delete(int projectId, int milestoneId) =>
_api.delete(projectId, milestoneId);
Future<void> delete(int projectId, int milestoneId) async {
try {
await _api.delete(projectId, milestoneId);
await _db.deleteMilestone(milestoneId);
} on NetworkException {
if (milestoneId < 0) {
final queued =
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteMilestone(milestoneId);
return;
}
await _db.deleteMilestone(milestoneId);
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbDelete,
targetId: milestoneId,
payload: {'project_id': projectId},
);
}
}
}
Milestone _applyMilestoneFields(Milestone m, Map<String, dynamic> f) {
return Milestone(
id: m.id,
projectId: m.projectId,
title: f['title'] as String? ?? m.title,
description: f.containsKey('description')
? f['description'] as String?
: m.description,
status: f['status'] as String? ?? m.status,
orderIndex: f['order_index'] as int? ?? m.orderIndex,
total: m.total,
completed: m.completed,
pct: m.pct,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
);
}
+155 -13
View File
@@ -1,12 +1,53 @@
import '../../core/exceptions.dart';
import '../api/notes_api.dart';
import '../local/database.dart';
import '../models/note.dart';
/// Notes repository with read-through caching for Tier 2 offline support
/// (Phase 1+2) and offline-write queueing (Phase 3).
///
/// Reads attempt the network first; on success the response is written to
/// the local Drift cache. On `NetworkException` the read falls back to the
/// cache (rethrowing if the cache is empty so the UI can show its
/// fresh-install empty state).
///
/// Writes hit the server then sync the cache. On `NetworkException` the
/// write is queued in `pending_writes` and applied optimistically to the
/// cache (negative temp-id for creates, in-place update for edits, removal
/// for deletes). Subsequent edits to a temp-id row are coalesced into the
/// queued create — only one server call is made per offline-created row.
///
/// `AuthStatus.offline` is owned by `AuthNotifier.verify()` (a periodic
/// heartbeat). This repository deliberately does not poke that state.
class NotesRepository {
final NotesApi _api;
const NotesRepository(this._api);
final FabledDatabase _db;
Future<List<Note>> getAll() => _api.getAll();
Future<Note> getOne(int id) => _api.getOne(id);
const NotesRepository(this._api, this._db);
Future<List<Note>> getAll() async {
try {
final notes = await _api.getAll();
await _db.replaceAllNotes(notes);
return notes;
} on NetworkException {
final cached = await _db.getAllNotes();
if (cached.isEmpty) rethrow;
return cached;
}
}
Future<Note> getOne(int id) async {
try {
final note = await _api.getOne(id);
await _db.upsertNote(note);
return note;
} on NetworkException {
final cached = await _db.getNote(id);
if (cached == null) rethrow;
return cached;
}
}
Future<Note> create(
String title,
@@ -14,9 +55,46 @@ class NotesRepository {
List<String> tags = const [],
int? projectId,
String noteType = 'note',
}) =>
_api.create(title, body,
tags: tags, projectId: projectId, noteType: noteType);
}) async {
try {
final note = await _api.create(
title,
body,
tags: tags,
projectId: projectId,
noteType: noteType,
);
await _db.upsertNote(note);
return note;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Note(
id: tempId,
title: title,
body: body,
tags: tags,
noteType: noteType,
projectId: projectId,
createdAt: now,
updatedAt: now,
);
await _db.upsertNote(optimistic);
await _db.enqueuePending(
domain: kSyncDomainNotes,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'title': title,
'body': body,
'tags': tags,
'project_id': projectId,
'note_type': noteType,
},
);
return optimistic;
}
}
Future<Note> update(
int id,
@@ -26,12 +104,76 @@ class NotesRepository {
int? projectId,
bool clearProject = false,
String noteType = 'note',
}) =>
_api.update(id, title, body,
tags: tags,
projectId: projectId,
clearProject: clearProject,
noteType: noteType);
}) async {
try {
final updated = await _api.update(
id,
title,
body,
tags: tags,
projectId: projectId,
clearProject: clearProject,
noteType: noteType,
);
await _db.upsertNote(updated);
return updated;
} on NetworkException {
final cached = await _db.getNote(id);
if (cached == null) rethrow;
final payload = <String, dynamic>{
'title': title,
'body': body,
'tags': tags,
'project_id': projectId,
'clear_project': clearProject,
'note_type': noteType,
};
final optimistic = cached.copyWith(
title: title,
body: body,
tags: tags,
noteType: noteType,
projectId: clearProject ? null : projectId,
);
await _db.upsertNote(optimistic);
// Edits to an offline-created row coalesce into the queued create.
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, payload);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainNotes,
verb: kWriteVerbUpdate,
targetId: id,
payload: payload,
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
}
Future<void> delete(int id) => _api.delete(id);
Future<void> delete(int id) async {
try {
await _api.delete(id);
await _db.deleteNote(id);
} on NetworkException {
// Deleting an offline-created row that never reached the server
// just drops the queued create — no server call needed.
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteNote(id);
return;
}
await _db.deleteNote(id);
await _db.enqueuePending(
domain: kSyncDomainNotes,
verb: kWriteVerbDelete,
targetId: id,
);
}
}
}
+151 -14
View File
@@ -1,31 +1,168 @@
import '../../core/exceptions.dart';
import '../api/projects_api.dart';
import '../local/database.dart';
import '../models/project.dart';
/// Projects repository with read-through caching (Phase 1+2) and offline-write
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
///
/// `getAll`'s sort/order/status query parameters are server-side filters; the
/// cache stores the unfiltered list as last seen. Offline fallback returns
/// the full cache regardless of the requested filters — close enough for a
/// "view what you had" experience while disconnected.
class ProjectsRepository {
final ProjectsApi _api;
const ProjectsRepository(this._api);
final FabledDatabase _db;
const ProjectsRepository(this._api, this._db);
Future<List<Project>> getAll({
String? status,
String sort = 'updated_at',
String order = 'desc',
}) =>
_api.getAll(status: status, sort: sort, order: order);
Future<Project> getOne(int id) => _api.getOne(id);
}) async {
try {
final projects =
await _api.getAll(status: status, sort: sort, order: order);
// Only refresh the cache on the unfiltered default fetch — otherwise a
// status=archived call would clobber the active-projects cache.
if (status == null) {
await _db.replaceAllProjects(projects);
} else {
for (final p in projects) {
await _db.upsertProject(p);
}
}
return projects;
} on NetworkException {
final cached = await _db.getAllProjects();
if (cached.isEmpty) rethrow;
if (status != null) {
return cached.where((p) => p.status == status).toList();
}
return cached;
}
}
Future<Project> getOne(int id) async {
try {
final project = await _api.getOne(id);
await _db.upsertProject(project);
return project;
} on NetworkException {
final cached = await _db.getProject(id);
if (cached == null) rethrow;
return cached;
}
}
Future<Project> create({
required String title,
String? description,
String? goal,
String? color,
String status = 'active',
}) =>
_api.create(
title: title,
description: description,
goal: goal,
color: color,
status: status);
Future<Project> update(int id, Map<String, dynamic> fields) =>
_api.update(id, fields);
Future<void> delete(int id) => _api.delete(id);
}) async {
try {
final project = await _api.create(
title: title,
description: description,
goal: goal,
color: color,
status: status,
);
await _db.upsertProject(project);
return project;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Project(
id: tempId,
title: title,
description: description,
goal: goal,
status: status,
color: color,
createdAt: now,
updatedAt: now,
);
await _db.upsertProject(optimistic);
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'title': title,
'description': description,
'goal': goal,
'color': color,
'status': status,
},
);
return optimistic;
}
}
Future<Project> update(int id, Map<String, dynamic> fields) async {
try {
final updated = await _api.update(id, fields);
await _db.upsertProject(updated);
return updated;
} on NetworkException {
final cached = await _db.getProject(id);
if (cached == null) rethrow;
final optimistic = _applyProjectFields(cached, fields);
await _db.upsertProject(optimistic);
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, fields);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbUpdate,
targetId: id,
payload: fields,
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
}
Future<void> delete(int id) async {
try {
await _api.delete(id);
await _db.deleteProject(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteProject(id);
return;
}
await _db.deleteProject(id);
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbDelete,
targetId: id,
);
}
}
}
Project _applyProjectFields(Project p, Map<String, dynamic> f) {
return Project(
id: p.id,
title: f['title'] as String? ?? p.title,
description:
f.containsKey('description') ? f['description'] as String? : p.description,
goal: f.containsKey('goal') ? f['goal'] as String? : p.goal,
status: f['status'] as String? ?? p.status,
color: f.containsKey('color') ? f['color'] as String? : p.color,
autoSummary: p.autoSummary,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
);
}
+170 -11
View File
@@ -1,12 +1,63 @@
import '../../core/exceptions.dart';
import '../api/tasks_api.dart';
import '../local/database.dart';
import '../models/task.dart';
/// Tasks repository with read-through caching (Phase 1+2) and offline-write
/// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
class TasksRepository {
final TasksApi _api;
const TasksRepository(this._api);
final FabledDatabase _db;
Future<List<Task>> getAll() => _api.getAll();
Future<Task> getOne(int id) => _api.getOne(id);
const TasksRepository(this._api, this._db);
Future<List<Task>> getAll() async {
try {
final tasks = await _api.getAll();
await _db.replaceAllTasks(tasks);
return tasks;
} on NetworkException {
final cached = await _db.getAllTasks();
if (cached.isEmpty) rethrow;
return cached;
}
}
Future<Task> getOne(int id) async {
try {
final task = await _api.getOne(id);
await _db.upsertTask(task);
return task;
} on NetworkException {
final cached = await _db.getTask(id);
if (cached == null) rethrow;
return cached;
}
}
Future<List<Task>> getByProject(int projectId) async {
try {
final tasks = await _api.getByProject(projectId);
for (final t in tasks) {
await _db.upsertTask(t);
}
return tasks;
} on NetworkException {
return _db.getTasksByProject(projectId);
}
}
Future<List<Task>> getSubTasks(int parentId) async {
try {
final tasks = await _api.getSubTasks(parentId);
for (final t in tasks) {
await _db.upsertTask(t);
}
return tasks;
} on NetworkException {
return _db.getSubTasks(parentId);
}
}
Future<Task> create({
required String title,
@@ -16,8 +67,9 @@ class TasksRepository {
DateTime? dueDate,
int? projectId,
int? parentId,
}) =>
_api.create(
}) async {
try {
final task = await _api.create(
title: title,
description: description,
status: status,
@@ -26,12 +78,119 @@ class TasksRepository {
projectId: projectId,
parentId: parentId,
);
await _db.upsertTask(task);
return task;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Task(
id: tempId,
title: title,
description: description,
status: status,
priority: priority,
dueDate: dueDate,
projectId: projectId,
parentId: parentId,
createdAt: now,
updatedAt: now,
);
await _db.upsertTask(optimistic);
await _db.enqueuePending(
domain: kSyncDomainTasks,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'title': title,
'description': description,
'status': status.value,
'priority': priority.value,
'due_date': dueDate?.toIso8601String(),
'project_id': projectId,
'parent_id': parentId,
},
);
return optimistic;
}
}
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
Future<Task> update(int id, Map<String, dynamic> fields) async {
try {
final updated = await _api.update(id, fields);
await _db.upsertTask(updated);
return updated;
} on NetworkException {
final cached = await _db.getTask(id);
if (cached == null) rethrow;
final optimistic = _applyTaskFields(cached, fields);
await _db.upsertTask(optimistic);
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, fields);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainTasks,
verb: kWriteVerbUpdate,
targetId: id,
payload: fields,
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
}
Future<Task> update(int id, Map<String, dynamic> fields) =>
_api.update(id, fields);
Future<void> delete(int id) => _api.delete(id);
Future<void> delete(int id) async {
try {
await _api.delete(id);
await _db.deleteTask(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteTask(id);
return;
}
await _db.deleteTask(id);
await _db.enqueuePending(
domain: kSyncDomainTasks,
verb: kWriteVerbDelete,
targetId: id,
);
}
}
}
/// Applies a partial-fields map (the same shape sent to the PUT endpoint)
/// to a cached Task so the UI can show the edit immediately.
Task _applyTaskFields(Task t, Map<String, dynamic> f) {
return Task(
id: t.id,
title: f['title'] as String? ?? t.title,
description:
f.containsKey('body') ? f['body'] as String? : t.description,
status: f.containsKey('status')
? TaskStatusExtension.fromString(f['status'] as String?)
: t.status,
priority: f.containsKey('priority')
? TaskPriorityExtension.fromString(f['priority'] as String?)
: t.priority,
dueDate: f.containsKey('due_date')
? (f['due_date'] is String
? DateTime.tryParse(f['due_date'] as String)
: null)
: t.dueDate,
projectId: f.containsKey('project_id')
? f['project_id'] as int?
: t.projectId,
milestoneId: f.containsKey('milestone_id')
? f['milestone_id'] as int?
: t.milestoneId,
parentId:
f.containsKey('parent_id') ? f['parent_id'] as int? : t.parentId,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
);
}
+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/auth_api.dart';
import '../data/api/briefing_api.dart';
import '../data/api/chat_api.dart';
import '../data/api/journal_api.dart';
import '../data/api/knowledge_api.dart';
import '../data/api/voice_api.dart';
import '../data/api/milestones_api.dart';
import '../data/api/events_api.dart';
import '../data/api/news_api.dart';
import '../data/api/notes_api.dart';
import '../data/api/projects_api.dart';
import '../data/api/quick_capture_api.dart';
import '../data/api/settings_api.dart';
import '../data/api/tasks_api.dart';
import '../data/local/database.dart';
import '../data/repositories/auth_repository.dart';
import '../data/repositories/chat_repository.dart';
import '../data/repositories/events_repository.dart';
import '../data/repositories/knowledge_repository.dart';
import '../data/repositories/voice_repository.dart';
import '../data/repositories/milestones_repository.dart';
import '../data/repositories/notes_repository.dart';
import '../data/repositories/projects_repository.dart';
import '../data/repositories/tasks_repository.dart';
import '../data/repositories/write_queue.dart';
import 'settings_provider.dart';
final cookieJarProvider = Provider<PersistCookieJar>((ref) {
@@ -61,24 +63,45 @@ final projectsApiProvider = Provider<ProjectsApi>((ref) {
return ProjectsApi(ref.watch(dioProvider));
});
/// Local SQLite cache used by repositories for read-through caching and
/// offline fallback. Backed by Drift; lives for the lifetime of the
/// ProviderScope and is closed automatically when the scope disposes.
final fabledDatabaseProvider = Provider<FabledDatabase>((ref) {
final db = FabledDatabase();
ref.onDispose(db.close);
return db;
});
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository(ref.watch(authApiProvider));
});
final notesRepositoryProvider = Provider<NotesRepository>((ref) {
return NotesRepository(ref.watch(notesApiProvider));
return NotesRepository(
ref.watch(notesApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
return TasksRepository(ref.watch(tasksApiProvider));
return TasksRepository(
ref.watch(tasksApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
return ChatRepository(ref.watch(chatApiProvider));
return ChatRepository(
ref.watch(chatApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
return ProjectsRepository(ref.watch(projectsApiProvider));
return ProjectsRepository(
ref.watch(projectsApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
@@ -86,7 +109,10 @@ final milestonesApiProvider = Provider<MilestonesApi>((ref) {
});
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
return MilestonesRepository(ref.watch(milestonesApiProvider));
return MilestonesRepository(
ref.watch(milestonesApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
@@ -97,8 +123,8 @@ final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
});
final briefingApiProvider = Provider<BriefingApi>((ref) {
return BriefingApi(ref.watch(dioProvider));
final journalApiProvider = Provider<JournalApi>((ref) {
return JournalApi(ref.watch(dioProvider));
});
final settingsApiProvider = Provider<SettingsApi>((ref) {
@@ -113,10 +139,54 @@ final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
return VoiceRepository(ref.watch(voiceApiProvider));
});
final newsApiProvider = Provider<NewsApi>((ref) {
return NewsApi(ref.watch(dioProvider));
});
final eventsApiProvider = Provider<EventsApi>((ref) {
return EventsApi(ref.watch(dioProvider));
});
final eventsRepositoryProvider = Provider<EventsRepository>((ref) {
return EventsRepository(
ref.watch(eventsApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
/// Phase 3 — drains the offline write queue. Listen on
/// `writeQueueFailuresProvider` to surface dropped ops to the user;
/// `writeQueueDepthProvider` powers the OfflineBanner's "Retry (N)" affordance.
final writeQueueProvider = Provider<WriteQueue>((ref) {
final queue = WriteQueue(
db: ref.watch(fabledDatabaseProvider),
notesApi: ref.watch(notesApiProvider),
tasksApi: ref.watch(tasksApiProvider),
projectsApi: ref.watch(projectsApiProvider),
milestonesApi: ref.watch(milestonesApiProvider),
eventsApi: ref.watch(eventsApiProvider),
);
ref.onDispose(queue.dispose);
return queue;
});
final writeQueueDepthProvider = StreamProvider<int>((ref) {
return ref.watch(fabledDatabaseProvider).watchQueueDepth();
});
final writeQueueFailuresProvider = StreamProvider<QueueFailure>((ref) {
return ref.watch(writeQueueProvider).failures;
});
/// Per-domain set of ids with a queued write (target_id temp_id). Phase 4
/// — used by `PendingSyncBadge` in list views to mark rows that are still
/// in flight.
final pendingNoteIdsProvider = StreamProvider<Set<int>>((ref) {
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainNotes);
});
final pendingTaskIdsProvider = StreamProvider<Set<int>>((ref) {
return ref.watch(fabledDatabaseProvider).watchPendingIds(kSyncDomainTasks);
});
final pendingProjectIdsProvider = StreamProvider<Set<int>>((ref) {
return ref
.watch(fabledDatabaseProvider)
.watchPendingIds(kSyncDomainProjects);
});
+9 -1
View File
@@ -1,8 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart';
import 'api_client_provider.dart';
import 'settings_provider.dart';
enum AuthStatus { unknown, authenticated, unauthenticated }
enum AuthStatus { unknown, authenticated, unauthenticated, offline }
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
@@ -14,7 +16,12 @@ class AuthNotifier extends Notifier<AuthStatus> {
try {
final repo = ref.read(authRepositoryProvider);
final ok = await repo.verify();
if (ok) {
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
}
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
} on NetworkException {
state = AuthStatus.offline;
} catch (_) {
state = AuthStatus.unauthenticated;
}
@@ -23,6 +30,7 @@ class AuthNotifier extends Notifier<AuthStatus> {
Future<void> login(String username, String password) async {
final repo = ref.read(authRepositoryProvider);
await repo.login(username, password);
await ref.read(hasEverLoggedInProvider.notifier).markLoggedIn();
state = AuthStatus.authenticated;
}
+3 -3
View File
@@ -47,7 +47,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
// Fetch current month ± 1 month as the initial window.
final from = DateTime(now.year, now.month - 1, 1);
final to = DateTime(now.year, now.month + 2, 0, 23, 59, 59);
final events = await ref.watch(eventsApiProvider).getEvents(from, to);
final events = await ref.watch(eventsRepositoryProvider).getEvents(from, to);
return CalendarState(
eventsByDay: _groupByDay(events),
selectedDay: today,
@@ -60,7 +60,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
Future<void> refresh() async {
final current = state.value;
if (current == null) return;
final events = await ref.read(eventsApiProvider).getEvents(
final events = await ref.read(eventsRepositoryProvider).getEvents(
current.loadedRange.start,
current.loadedRange.end,
);
@@ -93,7 +93,7 @@ class CalendarNotifier extends AsyncNotifier<CalendarState> {
try {
final from = DateTime(month.year, month.month, 1);
final to = DateTime(month.year, month.month + 1, 0, 23, 59, 59);
final events = await ref.read(eventsApiProvider).getEvents(from, to);
final events = await ref.read(eventsRepositoryProvider).getEvents(from, to);
final s = state.value!;
final merged = Map<DateTime, List<CalendarEvent>>.from(s.eventsByDay);
for (final e in events) {
@@ -3,12 +3,12 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.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 'api_client_provider.dart';
/// Drives the loading indicator in BriefingScreen's reply area.
final isBriefingStreamingProvider =
/// Drives the loading indicator in JournalScreen's reply area.
final isJournalStreamingProvider =
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
class _BoolNotifier extends Notifier<bool> {
@@ -16,23 +16,22 @@ class _BoolNotifier extends Notifier<bool> {
bool build() => false;
}
final briefingProvider =
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
BriefingNotifier.new);
final journalProvider =
AsyncNotifierProvider<JournalNotifier, JournalDay>(JournalNotifier.new);
class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
class JournalNotifier extends AsyncNotifier<JournalDay> {
@override
Future<BriefingConversation> build() async {
return ref.read(briefingApiProvider).getToday();
Future<JournalDay> build() async {
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.
Future<void> silentRefresh() async {
final current = state.value;
if (current == null) return;
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 newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
if (fresh.messages.length != current.messages.length ||
@@ -40,26 +39,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
state = AsyncData(fresh);
}
} catch (_) {
// Network hiccup silently ignore, keep existing content
// Network hiccup silently ignore, keep existing content.
}
}
/// Trigger a briefing slot (e.g. "compilation") then reload.
Future<void> refresh(String slot) async {
await ref.read(briefingApiProvider).triggerSlot(slot);
/// Force-regenerate today's daily prep then reload.
Future<void> regeneratePrep() async {
await ref.read(journalApiProvider).triggerPrep();
ref.invalidateSelf();
await future;
}
/// Re-fetch the current briefing conversation and unfreeze a stuck
/// streaming state if the server-side message is already complete.
/// Re-fetch today's journal and unfreeze a stuck streaming state if the
/// server-side message is already complete.
///
/// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE
/// socket dies silently (mobile network handoff, app backgrounded mid-stream,
/// reverse proxy dropping idle sockets) the send loop never observes close
/// and [isBriefingStreamingProvider] stays stuck true. This is the manual
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and the
/// lifecycle-resume hook.
/// socket dies silently the send loop never observes close and
/// [isJournalStreamingProvider] stays stuck true. This is the manual
/// recovery path hit by pull-to-refresh, the AppBar refresh button, and
/// the lifecycle-resume hook.
Future<void> refreshMessages() async {
final current = state.value;
if (current == null) {
@@ -67,7 +65,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
return;
}
try {
final fresh = await ref.read(briefingApiProvider).getToday();
final fresh = await ref.read(journalApiProvider).getToday();
state = AsyncData(fresh);
final messages = fresh.messages;
Message? lastAssistant;
@@ -78,46 +76,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
}
}
if (lastAssistant != null && lastAssistant.status != 'generating') {
ref.read(isBriefingStreamingProvider.notifier).state = false;
ref.read(isJournalStreamingProvider.notifier).state = false;
}
} catch (_) {
// Network hiccup keep existing state; user can retry.
}
}
/// Inject a news article as context and trigger generation.
///
/// 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.
/// Send a reply to today's journal conversation.
///
/// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same
/// stall-watchdog pattern:
@@ -126,12 +92,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
/// 3. SSE stream with per-event timeout (stall watchdog)
/// 4. Poll until complete
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;
final convId = conv.id;
final chatApi = ref.read(chatApiProvider);
final previous = conv.messages;
final previous = day.messages;
final userMsg = Message(
conversationId: convId,
role: MessageRole.user,
@@ -143,14 +111,14 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
content: '',
status: 'generating',
);
state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isBriefingStreamingProvider.notifier).state = true;
state = AsyncData(day.copyWith(messages: [...previous, userMsg, placeholder]));
ref.read(isJournalStreamingProvider.notifier).state = true;
try {
await chatApi.sendMessage(convId, content);
} catch (e) {
state = AsyncData(conv.copyWith(messages: previous));
ref.read(isBriefingStreamingProvider.notifier).state = false;
state = AsyncData(day.copyWith(messages: previous));
ref.read(isJournalStreamingProvider.notifier).state = false;
rethrow;
}
@@ -158,19 +126,15 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
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
/// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart.
/// Mobile networks occasionally drop SSE sockets silently: the TCP
/// connection is half-closed, Dio never sees the close, and `await for`
/// hangs forever with [isBriefingStreamingProvider] stuck true. If no event
/// arrives within the watchdog window we bail out and let the polling pass
/// below reconcile state from the server.
/// Mobile networks occasionally drop SSE sockets silently. If no event
/// arrives within the watchdog window we bail out and let polling
/// reconcile state from the server.
///
/// Returns whether any text content was actually streamed (the polling
/// pass uses this to decide whether it's safe to overwrite with a possibly
/// empty server-side row).
/// Returns whether any text content was actually streamed.
Future<bool> _consumeStream(Stream<ChatStreamEvent> stream) async {
const stallTimeout = Duration(seconds: 45);
bool streamedContent = false;
@@ -208,24 +172,25 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
return streamedContent;
}
/// Poll /api/briefing messages until the last assistant row is complete,
/// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears
/// [isBriefingStreamingProvider] at the end so the input can't stay locked.
/// Poll today's journal until the last assistant row is complete. Always
/// clears [isJournalStreamingProvider] at the end so the input can't stay
/// locked.
Future<void> _pollUntilComplete(int convId, bool streamedContent) async {
final briefingApi = ref.read(briefingApiProvider);
final journalApi = ref.read(journalApiProvider);
try {
for (var attempt = 0; attempt < 20; attempt++) {
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
final fresh = await briefingApi.getMessages(convId);
final done = fresh.any(
final fresh = await journalApi.getToday();
final freshMsgs = fresh.messages;
final done = freshMsgs.any(
(m) => m.role == MessageRole.assistant && m.status != 'generating',
);
final hasContent = fresh.any(
final hasContent = freshMsgs.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
);
final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh));
state = AsyncData(current.copyWith(messages: freshMsgs));
}
if (done) break;
}
@@ -241,7 +206,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
}
}
} 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:shared_preferences/shared_preferences.dart';
import 'api_client_provider.dart';
const _kServerUrl = 'server_url';
const _kThemeMode = 'theme_mode';
const _kForgejoRepoUrl = 'forgejo_repo_url';
const _kHasEverLoggedIn = 'has_ever_logged_in';
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Override in ProviderScope');
@@ -82,3 +85,47 @@ class ServerUrlNotifier extends Notifier<String?> {
state = null;
}
}
/// Tracks whether this install has ever completed a successful login.
/// Used so offline users who've logged in before land on the briefing (with a
/// banner) instead of being dumped onto the login screen as if freshly installed.
final hasEverLoggedInProvider =
NotifierProvider<HasEverLoggedInNotifier, bool>(HasEverLoggedInNotifier.new);
class HasEverLoggedInNotifier extends Notifier<bool> {
@override
bool build() {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getBool(_kHasEverLoggedIn) ?? false;
}
Future<void> markLoggedIn() async {
if (state) return;
await ref.read(sharedPreferencesProvider).setBool(_kHasEverLoggedIn, true);
state = true;
}
Future<void> clear() async {
await ref.read(sharedPreferencesProvider).remove(_kHasEverLoggedIn);
state = false;
}
}
final serverSettingsProvider =
AsyncNotifierProvider<ServerSettingsNotifier, Map<String, dynamic>>(
ServerSettingsNotifier.new);
class ServerSettingsNotifier extends AsyncNotifier<Map<String, dynamic>> {
@override
Future<Map<String, dynamic>> build() async {
try {
return await ref.read(settingsApiProvider).getAll();
} catch (_) {
return {};
}
}
Future<void> refresh() async {
state = AsyncData(await ref.read(settingsApiProvider).getAll());
}
}
+86 -32
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_file/open_file.dart';
@@ -5,7 +7,14 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
enum UpdateStatus {
idle,
checking,
downloading,
readyToInstall,
upToDate,
error,
}
class UpdateState {
final UpdateStatus status;
@@ -14,6 +23,7 @@ class UpdateState {
final String? downloadUrl;
final double downloadProgress;
final String? errorMessage;
final String? apkPath;
const UpdateState({
this.status = UpdateStatus.idle,
@@ -22,6 +32,7 @@ class UpdateState {
this.downloadUrl,
this.downloadProgress = 0.0,
this.errorMessage,
this.apkPath,
});
UpdateState copyWith({
@@ -31,6 +42,7 @@ class UpdateState {
String? downloadUrl,
double? downloadProgress,
String? errorMessage,
String? apkPath,
}) =>
UpdateState(
status: status ?? this.status,
@@ -39,6 +51,7 @@ class UpdateState {
downloadUrl: downloadUrl ?? this.downloadUrl,
downloadProgress: downloadProgress ?? this.downloadProgress,
errorMessage: errorMessage ?? this.errorMessage,
apkPath: apkPath ?? this.apkPath,
);
}
@@ -46,20 +59,15 @@ class UpdateNotifier extends Notifier<UpdateState> {
@override
UpdateState build() => const UpdateState();
/// [repoUrl] is the Forgejo repo page URL, e.g.
/// "https://git.example.com/user/fabled_app"
Future<void> check(String repoUrl) async {
state = state.copyWith(status: UpdateStatus.checking);
try {
final packageInfo = await PackageInfo.fromPlatform();
// Combine versionName + buildNumber to match the YY.MM.DD.N tag format.
final currentVersion =
'${packageInfo.version}.${packageInfo.buildNumber}';
// Parse repo URL → Forgejo API endpoint
final uri = Uri.parse(repoUrl);
final parts =
uri.pathSegments.where((s) => s.isNotEmpty).toList();
final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList();
if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)';
final apiUrl =
'${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest';
@@ -70,20 +78,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
tagName.startsWith('v') ? tagName.substring(1) : tagName;
if (_isNewer(latestVersion, currentVersion)) {
final assets =
(response.data['assets'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final assets = (response.data['assets'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final apk = assets.firstWhere(
(a) => (a['name'] as String? ?? '').endsWith('.apk'),
orElse: () => {},
);
if (apk.isNotEmpty) {
final downloadUrl = apk['browser_download_url'] as String?;
state = state.copyWith(
status: UpdateStatus.available,
currentVersion: currentVersion,
latestVersion: latestVersion,
downloadUrl: apk['browser_download_url'] as String?,
downloadUrl: downloadUrl,
);
await _downloadInBackground();
return;
}
}
@@ -101,11 +109,52 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
Future<void> downloadAndInstall() async {
Future<void> _downloadInBackground() async {
if (state.downloadUrl == null) return;
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try {
final dir = await _apkDir();
await _cleanupApks(dir);
final path = '${dir.path}/fabled_${state.latestVersion}.apk';
await Dio().download(
state.downloadUrl!,
path,
onReceiveProgress: (received, total) {
if (total > 0) {
state = state.copyWith(downloadProgress: received / total);
}
},
);
final file = File(path);
if (!await file.exists() || await file.length() == 0) {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage: 'Download failed — file is empty',
);
return;
}
state = state.copyWith(
status: UpdateStatus.readyToInstall,
apkPath: path,
downloadProgress: 1.0,
);
} catch (e) {
state = state.copyWith(
status: UpdateStatus.error,
errorMessage: e.toString(),
);
}
}
Future<void> install() async {
final path = state.apkPath;
if (path == null) return;
// Android 8+ requires explicit per-app "Install unknown apps" approval
// beyond the manifest declaration. Check and redirect to Settings if needed.
final installPermission = await Permission.requestInstallPackages.status;
if (!installPermission.isGranted) {
final result = await Permission.requestInstallPackages.request();
@@ -119,30 +168,13 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
try {
final dir = await getExternalStorageDirectory() ??
await getTemporaryDirectory();
final path = '${dir.path}/fabled_update.apk';
await Dio().download(
state.downloadUrl!,
path,
onReceiveProgress: (received, total) {
if (total > 0) {
state = state.copyWith(downloadProgress: received / total);
}
},
);
final result = await OpenFile.open(
path,
type: 'application/vnd.android.package-archive',
);
if (result.type == ResultType.done) {
// Installer launched — reset to idle so the dialog closes naturally.
state = const UpdateState();
} else {
state = state.copyWith(
@@ -158,8 +190,30 @@ class UpdateNotifier extends Notifier<UpdateState> {
}
}
/// Remove any previously cached APKs.
Future<void> cleanup() async {
final dir = await _apkDir();
await _cleanupApks(dir);
}
void dismiss() => state = const UpdateState();
Future<Directory> _apkDir() async {
return await getExternalStorageDirectory() ??
await getTemporaryDirectory();
}
Future<void> _cleanupApks(Directory dir) async {
try {
final entries = dir.listSync();
for (final entry in entries) {
if (entry is File && entry.path.endsWith('.apk')) {
await entry.delete();
}
}
} catch (_) {}
}
bool _isNewer(String latest, String current) {
try {
final l = latest.split('.').map(int.parse).toList();
+181 -144
View File
@@ -1,13 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
import 'package:vad/vad.dart';
import 'api_client_provider.dart';
@@ -51,6 +52,53 @@ String stripMarkdownForTts(String text) {
.trim();
}
/// Encode float PCM samples (-1..1) as a 16-bit mono WAV at 16 kHz.
Uint8List encodeWav(List<double> samples, {int sampleRate = 16000}) {
final numSamples = samples.length;
final dataSize = numSamples * 2;
final fileSize = 44 + dataSize;
final buf = ByteData(fileSize);
// RIFF header
buf.setUint8(0, 0x52); // R
buf.setUint8(1, 0x49); // I
buf.setUint8(2, 0x46); // F
buf.setUint8(3, 0x46); // F
buf.setUint32(4, fileSize - 8, Endian.little);
buf.setUint8(8, 0x57); // W
buf.setUint8(9, 0x41); // A
buf.setUint8(10, 0x56); // V
buf.setUint8(11, 0x45); // E
// fmt chunk
buf.setUint8(12, 0x66); // f
buf.setUint8(13, 0x6D); // m
buf.setUint8(14, 0x74); // t
buf.setUint8(15, 0x20); // (space)
buf.setUint32(16, 16, Endian.little); // chunk size
buf.setUint16(20, 1, Endian.little); // PCM format
buf.setUint16(22, 1, Endian.little); // mono
buf.setUint32(24, sampleRate, Endian.little);
buf.setUint32(28, sampleRate * 2, Endian.little); // byte rate
buf.setUint16(32, 2, Endian.little); // block align
buf.setUint16(34, 16, Endian.little); // bits per sample
// data chunk
buf.setUint8(36, 0x64); // d
buf.setUint8(37, 0x61); // a
buf.setUint8(38, 0x74); // t
buf.setUint8(39, 0x61); // a
buf.setUint32(40, dataSize, Endian.little);
for (var i = 0; i < numSamples; i++) {
final clamped = samples[i].clamp(-1.0, 1.0);
final int16 = (clamped * 32767).round().clamp(-32768, 32767);
buf.setInt16(44 + i * 2, int16, Endian.little);
}
return buf.buffer.asUint8List();
}
// ── State ─────────────────────────────────────────────────────────────────────
enum VoiceMode { idle, recording, transcribing, playing }
@@ -59,9 +107,7 @@ class VoiceState {
final VoiceMode mode;
final bool voiceModeActive;
final bool available;
/// Normalized mic amplitude 0.01.0 while recording. Drives the live
/// pulse on VoiceMicButton so the user has obvious feedback that audio
/// is actually being picked up.
/// Normalized mic amplitude 0.01.0 while recording.
final double amplitude;
const VoiceState({
@@ -88,35 +134,25 @@ class VoiceState {
// ── Provider ──────────────────────────────────────────────────────────────────
final voiceProvider =
NotifierProvider<VoiceNotifier, VoiceState>(VoiceNotifier.new);
NotifierProvider.autoDispose<VoiceNotifier, VoiceState>(VoiceNotifier.new);
// ── Notifier ──────────────────────────────────────────────────────────────────
class VoiceNotifier extends Notifier<VoiceState> {
// Audio I/O
AudioRecorder? _recorder;
// Audio playback
AudioPlayer? _player;
StreamSubscription<Amplitude>? _amplitudeSubscription;
// Recording / silence detection
//
// Silence threshold is dynamic: we track the session peak dBFS and treat
// "silent" as "current level is at least _dropFromPeakDb below peak."
// This auto-calibrates to whatever mic + room the user is on rather than
// assuming a fixed ambient level. Until the peak climbs above
// _dynamicArmDb we fall back to a conservative static threshold so a
// quiet room doesn't spin forever. A grace period at the start of the
// recording gives the user time to begin speaking before silence checks
// arm.
int _recordingStartMs = 0;
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;
// VAD — sole owner of the microphone
VadHandler? _vadHandler;
StreamSubscription<void>? _vadSpeechStartSub;
StreamSubscription<List<double>>? _vadSpeechEndSub;
StreamSubscription<({double isSpeech, double notSpeech, List<double> frame})>?
_vadFrameSub;
StreamSubscription<String>? _vadErrorSub;
bool _speechDetected = false;
int _speechStartMs = 0;
static const _vadGraceMs = 1500;
bool _disposed = false;
// Voice mode callbacks
Future<void> Function(String transcript)? _onTranscript;
@@ -128,11 +164,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
int _lastSeenLength = 0;
bool _streamComplete = false;
// Last complete assistant response — passed to Whisper as initial_prompt
// to reduce STT mishearings of domain-specific words.
// Whisper context hint
String _lastAssistantContent = '';
// Empty transcript counter — show feedback after consecutive blanks
// Empty transcript counter
int _emptyTranscriptCount = 0;
// TTS playback queue
@@ -143,52 +178,51 @@ class VoiceNotifier extends Notifier<VoiceState> {
@override
VoiceState build() {
_disposed = false;
_player = AudioPlayer();
ref.onDispose(() {
_amplitudeSubscription?.cancel();
_recorder?.dispose();
_disposed = true;
_cancelSubscriptions();
_vadHandler?.dispose();
_vadHandler = null;
_player?.dispose();
_player = null;
});
return const VoiceState();
}
// ── 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({
required Future<void> Function(String transcript) onTranscript,
bool enableTts = false,
required void Function(String message) onError,
}) async {
if (state.voiceModeActive) {
if (state.mode == VoiceMode.recording && !_speechDetected) {
onError('No speech detected');
}
exitVoiceMode();
return;
}
// Check server availability — STT is required, TTS is optional.
try {
final status = await ref.read(voiceRepositoryProvider).checkStatus();
if (!status.enabled || !status.stt) {
onError('Speech-to-text not available on this server');
return;
}
// Downgrade to STT-only when TTS is unavailable
if (!status.tts) enableTts = false;
} catch (_) {
onError('Could not reach voice service');
return;
}
// Check microphone permission
var permStatus = await Permission.microphone.request();
if (permStatus == PermissionStatus.permanentlyDenied) {
onError('Microphone blocked — opening settings');
final opened = await openAppSettings();
if (!opened) return;
// Re-check after user returns from settings
permStatus = await Permission.microphone.status;
}
if (!permStatus.isGranted) {
@@ -206,25 +240,11 @@ class VoiceNotifier extends Notifier<VoiceState> {
await _startListening();
}
/// Exit voice mode, stop all recording and TTS.
void exitVoiceMode() {
_amplitudeSubscription?.cancel();
_amplitudeSubscription = null;
_recorder?.stop();
_player?.stop();
_ttsQueue.clear();
_ttsPlaying = false;
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
_onTranscript = null;
_onError = null;
state = const VoiceState();
_cleanup();
if (!_disposed) 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}) {
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 ──────────────────────────────────────────────────────
Future<void> _startListening() async {
if (!state.voiceModeActive) return;
if (_disposed || !state.voiceModeActive) return;
_silenceMs = 0;
_peakDb = -100.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';
_speechDetected = false;
_speechStartMs = 0;
try {
// Recreate the recorder each session — the record package can leave
// the native AudioRecord in a bad state after stop/error cycles,
// and a stale instance is the most common cause of "could not start".
_recorder?.dispose();
_recorder = AudioRecorder();
await _stopVad();
_vadHandler = VadHandler.create();
if (!await _recorder!.hasPermission()) {
_onError?.call('Microphone permission was revoked');
exitVoiceMode();
return;
_vadSpeechStartSub = _vadHandler!.onSpeechStart.listen((_) {
if (_disposed || !state.voiceModeActive) return;
if (!_speechDetected) {
_speechDetected = true;
_speechStartMs = DateTime.now().millisecondsSinceEpoch;
}
});
_vadSpeechEndSub = _vadHandler!.onSpeechEnd.listen((audioSamples) {
if (_disposed || !state.voiceModeActive) return;
final now = DateTime.now().millisecondsSinceEpoch;
final sinceStart = _speechStartMs > 0 ? now - _speechStartMs : 0;
if (_speechDetected && sinceStart >= _vadGraceMs) {
_stopVad();
_handleSpeechEnd(audioSamples);
}
});
_vadFrameSub = _vadHandler!.onFrameProcessed.listen((event) {
if (_disposed || !state.voiceModeActive) return;
final frame = event.frame;
if (frame.isEmpty) return;
double sumSq = 0;
for (final s in frame) {
sumSq += s * s;
}
final rms = sqrt(sumSq / frame.length);
final norm = (rms * 4.0).clamp(0.0, 1.0);
if ((norm - state.amplitude).abs() > 0.02) {
state = state.copyWith(amplitude: norm);
}
});
_vadErrorSub = _vadHandler!.onError.listen((msg) {
if (_disposed) return;
_onError?.call('VAD error: $msg');
});
await _vadHandler!.startListening(model: 'v5');
// Only show recording UI after the mic is actually open.
if (!_disposed && state.voiceModeActive) {
state = state.copyWith(mode: VoiceMode.recording, amplitude: 0.0);
}
await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.aacLc, sampleRate: 16000),
path: path,
);
_amplitudeSubscription?.cancel();
_amplitudeSubscription = _recorder!
.onAmplitudeChanged(const Duration(milliseconds: 200))
.listen(_onAmplitude);
} catch (e) {
_onError?.call('Microphone error: $e');
exitVoiceMode();
_cleanup();
if (!_disposed) state = const VoiceState();
}
}
void _onAmplitude(Amplitude event) {
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) {
state = state.copyWith(amplitude: norm);
}
if (db > _peakDb) _peakDb = db;
}
final elapsed =
DateTime.now().millisecondsSinceEpoch - _recordingStartMs;
// Grace period at the start — let the user begin speaking before we
// 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> _stopVad() async {
_cancelSubscriptions();
if (_vadHandler != null) {
final handler = _vadHandler!;
_vadHandler = null;
await handler.dispose();
}
}
Future<void> _handleSilence() async {
if (!state.voiceModeActive) return;
Future<void> _handleSpeechEnd(List<double> audioSamples) async {
if (_disposed || !state.voiceModeActive) return;
state = state.copyWith(mode: VoiceMode.transcribing);
final path = await _recorder!.stop();
if (path == null || !state.voiceModeActive) return;
try {
final bytes = await File(path).readAsBytes();
await File(path).delete().catchError((_) => File(path));
final wavBytes = encodeWav(audioSamples);
if (!state.voiceModeActive) return;
if (_disposed || !state.voiceModeActive) return;
final transcript = await ref.read(voiceRepositoryProvider).transcribe(
bytes,
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
wavBytes,
context:
_lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
);
if (!state.voiceModeActive) return;
if (_disposed || !state.voiceModeActive) return;
if (transcript.isEmpty) {
_emptyTranscriptCount++;
if (_emptyTranscriptCount >= 3) {
_onError?.call('No speech detected — tap the mic to try again');
exitVoiceMode();
_cleanup();
if (!_disposed) state = const VoiceState();
return;
}
await _startListening();
@@ -360,24 +395,25 @@ class VoiceNotifier extends Notifier<VoiceState> {
}
_emptyTranscriptCount = 0;
// Reset TTS state for this new turn
_sentenceBuffer = '';
_lastSeenLength = 0;
_streamComplete = false;
if (_enableTts) {
if (_enableTts && !_disposed) {
state = state.copyWith(mode: VoiceMode.playing);
}
await _onTranscript?.call(transcript);
// If TTS is not enabled, loop immediately
if (!_enableTts && state.voiceModeActive) {
await _startListening();
// In STT-only mode (no TTS), return to idle after transcript is sent.
// The user taps the mic again to record another message.
if (!_enableTts && !_disposed && state.voiceModeActive) {
state = state.copyWith(mode: VoiceMode.idle);
}
} catch (e) {
_onError?.call('Voice error: transcription failed');
exitVoiceMode();
_cleanup();
if (!_disposed) state = const VoiceState();
}
}
@@ -405,7 +441,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
try {
final wavBytes =
await ref.read(voiceRepositoryProvider).synthesise(text);
if (!state.voiceModeActive) return;
if (_disposed || !state.voiceModeActive) return;
_ttsQueue.add(wavBytes);
if (!_ttsPlaying) _drainTtsQueue();
} catch (_) {
@@ -444,6 +480,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
}
void _checkRestartListening() {
if (_disposed) return;
if (_streamComplete &&
_ttsQueue.isEmpty &&
!_ttsPlaying &&
+6 -5
View File
@@ -1,6 +1,7 @@
import 'dart:io' as io;
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -72,7 +73,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
_usernameController.text.trim(),
_passwordController.text,
);
if (mounted) context.go(Routes.briefing);
if (mounted) context.go(Routes.journal);
} on AuthException catch (e) {
setState(() => _error = e.message);
} on AppException catch (e) {
@@ -90,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
cookieJar: ref.read(cookieJarProvider),
onSuccess: () async {
await ref.read(authProvider.notifier).verify();
if (mounted) context.go(Routes.briefing);
if (mounted) context.go(Routes.journal);
},
),
));
@@ -119,7 +120,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
const SizedBox(height: 32),
if (_oauthEnabled) ...[
FilledButton.icon(
icon: const Icon(Icons.login),
icon: const Icon(LucideIcons.logIn),
label: const Text('Sign in with SSO'),
onPressed: _openOAuth,
),
@@ -155,8 +156,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscure
? Icons.visibility
: Icons.visibility_off),
? LucideIcons.eye
: LucideIcons.eyeOff),
onPressed: () =>
setState(() => _obscure = !_obscure),
),
@@ -1,88 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/models/briefing_conversation.dart';
import '../../data/models/message.dart';
import '../../providers/api_client_provider.dart';
import '../../widgets/chat_message_bubble.dart';
class BriefingHistoryScreen extends ConsumerWidget {
const BriefingHistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyAsync = ref.watch(_briefingHistoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('Past Briefings')),
body: historyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) =>
const Center(child: Text('Could not load briefing history.')),
data: (convs) {
if (convs.isEmpty) {
return const Center(child: Text('No past briefings.'));
}
return ListView.builder(
itemCount: convs.length,
itemBuilder: (context, i) {
final conv = convs[i];
final label = conv.briefingDate ?? conv.title;
return ListTile(
leading: const Icon(Icons.wb_sunny_outlined),
title: Text(label),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _BriefingDetailScreen(conv: conv),
),
),
);
},
);
},
),
);
}
}
/// Lazily loads and displays all messages for a past briefing.
class _BriefingDetailScreen extends ConsumerWidget {
final BriefingConversation conv;
const _BriefingDetailScreen({required this.conv});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesAsync = ref.watch(_briefingMessagesProvider(conv.id));
return Scaffold(
appBar: AppBar(title: Text(conv.briefingDate ?? conv.title)),
body: messagesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const Center(child: Text('Could not load messages.')),
data: (messages) {
if (messages.isEmpty) {
return const Center(child: Text('No messages.'));
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
itemCount: messages.length,
itemBuilder: (_, i) => ChatMessageBubble(message: messages[i]),
);
},
),
);
}
}
// ── Private providers (scoped to this file) ──────────────────────────────────
final _briefingHistoryProvider =
FutureProvider<List<BriefingConversation>>((ref) async {
return ref.watch(briefingApiProvider).getHistory();
});
final _briefingMessagesProvider =
FutureProvider.family<List<Message>, int>((ref, convId) async {
return ref.watch(briefingApiProvider).getMessages(convId);
});
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:table_calendar/table_calendar.dart';
@@ -91,7 +92,7 @@ class CalendarScreen extends ConsumerWidget {
notifier: notifier,
),
),
child: const Icon(Icons.add),
child: const Icon(LucideIcons.plus),
)
: null,
);
+22 -18
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme.dart';
import '../../data/models/calendar_event.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/calendar_provider.dart';
@@ -118,12 +120,12 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
'color': _color,
'recurrence': rrule,
};
final api = ref.read(eventsApiProvider);
final repo = ref.read(eventsRepositoryProvider);
if (_isCreate) {
final created = await api.createEvent(payload);
final created = await repo.createEvent(payload);
widget.notifier.addEvent(created);
} else {
final updated = await api.updateEvent(widget.event!.id, payload);
final updated = await repo.updateEvent(widget.event!.id, payload);
widget.notifier.updateEvent(updated);
}
if (mounted) Navigator.of(context).pop();
@@ -152,11 +154,10 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(
'Delete',
style: TextStyle(
color: Theme.of(dialogContext).colorScheme.error),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete'),
),
],
),
@@ -164,7 +165,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
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);
if (mounted) Navigator.of(context).pop();
} catch (_) {
@@ -273,7 +274,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
const Spacer(),
if (!_isCreate)
IconButton(
icon: const Icon(Icons.delete_outline),
icon: const Icon(LucideIcons.trash2),
color: Theme.of(context).colorScheme.error,
onPressed: _saving ? null : _delete,
),
@@ -303,7 +304,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
// Start date
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined),
leading: const Icon(LucideIcons.calendarDays),
title: Text(_fmtDate(_startDt)),
subtitle: const Text('Start date'),
onTap: _pickStartDate,
@@ -313,7 +314,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
if (!_allDay)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time_outlined),
leading: const Icon(LucideIcons.clock),
title: Text(_fmtTime(_startDt)),
subtitle: const Text('Start time'),
onTap: _pickStartTime,
@@ -322,14 +323,14 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
// End date
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.event_outlined),
leading: const Icon(LucideIcons.calendarCheck),
title: Text(
_endDt != null ? _fmtDate(_endDt!) : 'No end date'),
subtitle: const Text('End date'),
onTap: _pickEndDate,
trailing: _endDt != null
? IconButton(
icon: const Icon(Icons.clear),
icon: const Icon(LucideIcons.x),
onPressed: () => setState(() => _endDt = null),
)
: null,
@@ -339,7 +340,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
if (!_allDay && _endDt != null)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time_outlined),
leading: const Icon(LucideIcons.clock),
title: Text(_fmtTime(_endDt!)),
subtitle: const Text('End time'),
onTap: _pickEndTime,
@@ -351,14 +352,14 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
if (_isCustomRrule)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.repeat_outlined),
leading: const Icon(LucideIcons.repeat),
title: const Text('Custom (read-only)'),
subtitle: Text(widget.event!.recurrence ?? ''),
)
else
Row(
children: [
const Icon(Icons.repeat_outlined),
const Icon(LucideIcons.repeat),
const SizedBox(width: 16),
DropdownButton<String?>(
value: _recurrence,
@@ -419,7 +420,7 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
width: _color.isEmpty ? 2 : 1,
),
),
child: const Icon(Icons.block, size: 16),
child: const Icon(LucideIcons.ban, size: 16),
),
),
..._presetColors.map((hex) {
@@ -451,10 +452,13 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
const SizedBox(height: 24),
// Save button
// Save button — Moss action-primary per Hybrid rule
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).extension<ActionColors>()!.primary,
),
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
+3 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
@@ -68,8 +69,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
// Exit voice mode if the user navigates away mid-session.
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -175,7 +174,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
: const Icon(LucideIcons.refreshCw),
),
],
),
@@ -282,7 +281,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
child:
CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
: const Icon(LucideIcons.send),
),
],
),
+11 -6
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/theme.dart';
import '../../providers/chat_provider.dart';
import 'chat_screen.dart';
@@ -51,6 +53,9 @@ class _ConversationsTabScreenState
child: const Text('Cancel')),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete')),
],
),
@@ -74,7 +79,7 @@ class _ConversationsTabScreenState
title: Text('Chat', style: theme.textTheme.titleLarge),
actions: [
IconButton(
icon: const Icon(Icons.add),
icon: const Icon(LucideIcons.plus),
tooltip: 'New conversation',
onPressed: _createConversation,
),
@@ -89,14 +94,14 @@ class _ConversationsTabScreenState
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline,
Icon(LucideIcons.messageCircle,
size: 48, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text('No conversations yet',
style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
FilledButton.icon(
icon: const Icon(Icons.add),
icon: const Icon(LucideIcons.plus),
label: const Text('Start a conversation'),
onPressed: _createConversation,
),
@@ -113,7 +118,7 @@ class _ConversationsTabScreenState
final c = convs[i];
final selected = isWide && c.id == _selectedConvId;
return ListTile(
leading: const Icon(Icons.chat_bubble_outline),
leading: const Icon(LucideIcons.messageCircle),
title: Text(
c.title.isEmpty ? 'New conversation' : c.title,
maxLines: 1,
@@ -124,7 +129,7 @@ class _ConversationsTabScreenState
),
selected: selected,
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
icon: const Icon(LucideIcons.trash2),
onPressed: () => _confirmDelete(c.id, c.title),
),
onTap: () => _openConversation(c.id),
@@ -155,7 +160,7 @@ class _ConversationsTabScreenState
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline,
Icon(LucideIcons.messageCircle,
size: 48,
color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
@@ -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 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/exceptions.dart';
import '../../data/models/message.dart';
import '../../providers/briefing_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/journal_provider.dart';
import '../../providers/voice_provider.dart';
import '../../widgets/chat_message_bubble.dart';
import '../../widgets/voice_mic_button.dart';
import 'journal_history_screen.dart';
class BriefingScreen extends ConsumerStatefulWidget {
const BriefingScreen({super.key});
class JournalScreen extends ConsumerStatefulWidget {
const JournalScreen({super.key});
@override
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
ConsumerState<JournalScreen> createState() => _JournalScreenState();
}
class _BriefingScreenState extends ConsumerState<BriefingScreen>
class _JournalScreenState extends ConsumerState<JournalScreen>
with WidgetsBindingObserver {
final _controller = TextEditingController();
final _scrollController = ScrollController();
bool _refreshing = false;
// rss_item_id -> 'up' | 'down' | null
final Map<int, String?> _reactions = {};
Timer? _pollTimer;
bool _appInForeground = true;
@@ -36,31 +32,29 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
_pollTimer =
Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasBackground = !_appInForeground;
_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) {
ref.read(briefingProvider.notifier).refreshMessages();
ref.read(journalProvider.notifier).refreshMessages();
}
}
void _pollSilently() {
if (!_appInForeground || !mounted) return;
final isStreaming = ref.read(isBriefingStreamingProvider);
final isStreaming = ref.read(isJournalStreamingProvider);
if (isStreaming) return;
ref.read(briefingProvider.notifier).silentRefresh();
ref.read(journalProvider.notifier).silentRefresh();
}
Future<void> _pullToRefresh() async {
try {
await ref.read(briefingProvider.notifier).refreshMessages();
await ref.read(journalProvider.notifier).refreshMessages();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -76,7 +70,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
_scrollController.dispose();
ref.read(voiceProvider.notifier).exitVoiceMode();
super.dispose();
}
@@ -97,7 +90,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
if (text.isEmpty) return;
_controller.clear();
try {
await ref.read(briefingProvider.notifier).sendReply(text);
await ref.read(journalProvider.notifier).sendReply(text);
} on AppException catch (e) {
if (mounted) {
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 {
final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) {
@@ -153,7 +113,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
}
await ref.read(voiceProvider.notifier).enterVoiceMode(
onTranscript: (transcript) async {
await ref.read(briefingProvider.notifier).sendReply(transcript);
await ref.read(journalProvider.notifier).sendReply(transcript);
},
enableTts: true,
onError: (msg) {
@@ -168,11 +128,11 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
Future<void> _refresh() async {
setState(() => _refreshing = true);
try {
await ref.read(briefingProvider.notifier).refresh('compilation');
await ref.read(journalProvider.notifier).regeneratePrep();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not generate briefing.')),
const SnackBar(content: Text('Could not regenerate prep.')),
);
}
} finally {
@@ -182,20 +142,19 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
@override
Widget build(BuildContext context) {
final briefingAsync = ref.watch(briefingProvider);
final isStreaming = ref.watch(isBriefingStreamingProvider);
final journalAsync = ref.watch(journalProvider);
final isStreaming = ref.watch(isJournalStreamingProvider);
final voiceState = ref.watch(voiceProvider);
final scheme = Theme.of(context).colorScheme;
// Scroll to bottom when messages change
ref.listen(briefingProvider, (prev, next) => _scrollToBottom());
ref.listen(journalProvider, (prev, next) => _scrollToBottom());
// Feed streaming assistant content to VoiceNotifier for TTS.
ref.listen(briefingProvider, (prev, next) {
ref.listen(journalProvider, (prev, next) {
if (!voiceState.voiceModeActive) return;
final conv = next.value;
if (conv == null || conv.messages.isEmpty) return;
final last = conv.messages.last;
final day = next.value;
if (day == null || day.messages.isEmpty) return;
final last = day.messages.last;
if (last.role != MessageRole.assistant) return;
final isComplete = last.status != 'generating';
ref
@@ -208,7 +167,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Briefing', style: Theme.of(context).textTheme.titleLarge),
Text('Journal', style: Theme.of(context).textTheme.titleLarge),
Text(
_todayLabel(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
@@ -229,43 +188,43 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
)
else
IconButton(
icon: const Icon(Icons.refresh_outlined),
tooltip: 'Generate briefing',
icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Regenerate prep',
onPressed: _refresh,
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'history') {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const BriefingHistoryScreen(),
builder: (_) => const JournalHistoryScreen(),
));
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'history',
child: Text('View past briefings'),
child: Text('Past days'),
),
],
),
],
),
body: briefingAsync.when(
body: journalAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Could not load today's briefing."),
const Text("Could not load today's journal."),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.invalidate(briefingProvider),
onPressed: () => ref.invalidate(journalProvider),
child: const Text('Retry'),
),
],
),
),
data: (conv) {
data: (day) {
final isWide = MediaQuery.of(context).size.width >= 600;
Widget body = Column(
children: [
@@ -274,63 +233,49 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
onRefresh: _pullToRefresh,
child: CustomScrollView(
controller: _scrollController,
// AlwaysScrollable so pull-to-refresh fires even when
// the briefing is empty or shorter than the viewport.
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (conv.messages.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'No briefing yet today.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('Generate now'),
),
],
if (day.messages.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'No prep yet today.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _refresh,
child: const Text('Generate now'),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
sliver: SliverList.builder(
itemCount: day.messages.length,
itemBuilder: (_, i) =>
_JournalMessageItem(message: day.messages[i]),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
sliver: SliverList.builder(
itemCount: conv.messages.length,
itemBuilder: (_, i) {
final msg = conv.messages[i];
return _BriefingMessageItem(
message: msg,
convId: conv.id,
reactions: _reactions,
onReaction: _handleReaction,
onDiscuss: _handleDiscuss,
);
},
),
),
],
),
),
),
// Progress bar while streaming
if (isStreaming)
LinearProgressIndicator(
minHeight: 2,
color: scheme.primary,
),
// Voice mode banner
if (voiceState.voiceModeActive)
Container(
width: double.infinity,
@@ -345,7 +290,6 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
),
),
),
// Reply bar
const Divider(height: 1),
SafeArea(
child: Padding(
@@ -358,7 +302,7 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
decoration: InputDecoration(
hintText: voiceState.voiceModeActive
? 'Listening…'
: 'Reply to your briefing',
: 'Tell your journal',
hintStyle: voiceState.voiceModeActive
? const TextStyle(fontStyle: FontStyle.italic)
: null,
@@ -421,62 +365,21 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen>
}
}
/// Renders a single briefing message with optional WeatherCard above it
/// and RSS reaction buttons below it (for assistant messages with metadata).
class _BriefingMessageItem extends StatelessWidget {
/// Renders a single journal message. The daily-prep prose itself already
/// covers weather ("Weather at home will reach a high of 15.9° ..."), so
/// the journal screen leaves rendering to the message bubble no separate
/// weather card on top.
class _JournalMessageItem extends StatelessWidget {
final Message message;
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({
required this.message,
required this.convId,
required this.reactions,
required this.onReaction,
required this.onDiscuss,
});
const _JournalMessageItem({required this.message});
@override
Widget build(BuildContext context) {
final meta = message.metadata;
final isAssistant = message.role == MessageRole.assistant;
// Weather: show card above when metadata.weather key is present (even if null value)
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
// RSS 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(),
),
),
],
);
return ChatMessageBubble(message: message);
}
}
class _GradientSendButton extends StatelessWidget {
final VoidCallback? onPressed;
final bool isStreaming;
@@ -498,7 +401,7 @@ class _GradientSendButton extends StatelessWidget {
: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF7C3AED), Color(0xFF5B21B6)],
colors: [Color(0xFF5B4A8A), Color(0xFF3F3560)],
),
color: disabled ? scheme.onSurface.withValues(alpha: 0.12) : null,
borderRadius: BorderRadius.circular(10),
@@ -514,7 +417,7 @@ class _GradientSendButton extends StatelessWidget {
),
)
: Icon(
Icons.send,
LucideIcons.send,
color: disabled
? scheme.onSurface.withValues(alpha: 0.38)
: Colors.white,
+7 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -95,7 +96,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
: const Text('Knowledge'),
actions: [
IconButton(
icon: Icon(_searchActive ? Icons.close : Icons.search),
icon: Icon(_searchActive ? LucideIcons.x : LucideIcons.search),
onPressed: () {
setState(() => _searchActive = !_searchActive);
if (!_searchActive) {
@@ -149,7 +150,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
floatingActionButton: FloatingActionButton(
onPressed: () => _onFabTapped(context),
tooltip: 'New',
child: const Icon(Icons.edit_outlined),
child: const Icon(LucideIcons.pencil),
),
);
}
@@ -259,7 +260,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
mainAxisSize: MainAxisSize.min,
children: [
_TypePickerRow(
icon: Icons.description_outlined,
icon: LucideIcons.fileText,
label: 'Note',
description: 'General note or document',
onTap: () {
@@ -268,7 +269,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
},
),
_TypePickerRow(
icon: Icons.person_outlined,
icon: LucideIcons.user,
label: 'Person',
description: 'Contact, colleague, or reference person',
onTap: () {
@@ -277,7 +278,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
},
),
_TypePickerRow(
icon: Icons.place_outlined,
icon: LucideIcons.mapPin,
label: 'Place',
description: 'Location, venue, or place of interest',
onTap: () {
@@ -286,7 +287,7 @@ class _KnowledgeScreenState extends ConsumerState<KnowledgeScreen>
},
),
_TypePickerRow(
icon: Icons.checklist_outlined,
icon: LucideIcons.listChecks,
label: 'List',
description: 'Checklist or structured list',
onTap: () {
+32 -19
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../data/local/database.dart';
import '../../data/models/milestone.dart';
import '../../data/models/project.dart';
import '../../data/models/task.dart';
@@ -10,6 +12,7 @@ import '../../providers/api_client_provider.dart';
import '../../providers/milestones_provider.dart';
import '../../providers/projects_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../widgets/pending_sync_badge.dart';
class ProjectTasksScreen extends ConsumerStatefulWidget {
final int projectId;
@@ -55,11 +58,11 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
}
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return const Color(0xFF7C3AED);
if (hex == null || hex.isEmpty) return const Color(0xFF5B4A8A);
try {
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
} catch (_) {
return const Color(0xFF7C3AED);
return const Color(0xFF5B4A8A);
}
}
@@ -96,7 +99,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
),
actions: [
IconButton(
icon: const Icon(Icons.edit_outlined),
icon: const Icon(LucideIcons.pencil),
tooltip: 'Edit project',
onPressed: () =>
context.push('/projects/${widget.projectId}/edit'),
@@ -133,7 +136,7 @@ class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_box_outlined,
Icon(LucideIcons.checkSquare,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
@@ -302,9 +305,9 @@ class _TaskRow extends StatelessWidget {
});
IconData get _statusIcon => switch (effectiveStatus) {
TaskStatus.done => Icons.check_circle,
TaskStatus.inProgress => Icons.timelapse,
TaskStatus.todo => Icons.radio_button_unchecked,
TaskStatus.done => LucideIcons.checkCircle2,
TaskStatus.inProgress => LucideIcons.loader,
TaskStatus.todo => LucideIcons.circle,
};
Color _statusColor(BuildContext context) {
@@ -345,18 +348,28 @@ class _TaskRow extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: effectiveStatus == TaskStatus.done
? TextDecoration.lineThrough
: null,
color: effectiveStatus == TaskStatus.done
? theme.colorScheme.onSurfaceVariant
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
Row(
children: [
Flexible(
child: Text(
task.title.isNotEmpty ? task.title : 'Untitled',
style: theme.textTheme.titleSmall?.copyWith(
decoration: effectiveStatus == TaskStatus.done
? TextDecoration.lineThrough
: null,
color: effectiveStatus == TaskStatus.done
? theme.colorScheme.onSurfaceVariant
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
PendingSyncBadge(
domain: kSyncDomainTasks,
id: task.id,
),
],
),
if (task.dueDate != null) ...[
const SizedBox(height: 2),
-236
View File
@@ -1,236 +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.')),
);
}
}
}
Widget _buildNewsItem(NewsState news, int i, {int cols = 1}) {
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],
snippetMaxLines: cols > 1 ? 5 : 2,
onReaction: (itemId, reaction) =>
ref.read(newsProvider.notifier).toggleReaction(itemId, reaction),
onDiscuss: _openingChat.contains(item.id)
? null
: () => _handleDiscuss(item.id),
);
}
@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: LayoutBuilder(
builder: (context, constraints) {
final cols = constraints.maxWidth >= 900
? 3
: constraints.maxWidth >= 600
? 2
: 1;
if (cols == 1) {
return ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
itemCount: news.items.length + 1,
itemBuilder: (_, i) =>
_buildNewsItem(news, i),
);
}
return CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid(
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.6,
),
delegate: SliverChildBuilderDelegate(
(_, i) => _buildNewsItem(news, i, cols: cols),
childCount: news.items.length,
),
),
),
if (news.hasMore)
SliverToBoxAdapter(
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: news.loadingMore
? const CircularProgressIndicator()
: FilledButton.tonal(
onPressed: _loadMore,
child: const Text('Load more'),
),
),
),
),
],
);
},
),
),
),
],
),
),
);
}
}
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:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/theme.dart';
import '../../core/wikilink_syntax.dart';
import '../../providers/notes_provider.dart';
@@ -44,13 +46,13 @@ class NoteDetailScreen extends ConsumerWidget {
data: (note) => Row(
children: [
IconButton(
icon: const Icon(Icons.edit),
icon: const Icon(LucideIcons.pencil),
onPressed: () => context.push(
Routes.noteEdit.replaceFirst(':id', '$noteId'),
),
),
IconButton(
icon: const Icon(Icons.delete),
icon: const Icon(LucideIcons.trash2),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
@@ -65,6 +67,11 @@ class NoteDetailScreen extends ConsumerWidget {
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext)
.extension<ActionColors>()!
.destructive,
),
child: const Text('Delete'),
),
],
+9 -4
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/exceptions.dart';
import '../../core/theme.dart';
import '../../core/wikilink_syntax.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/notes_provider.dart';
@@ -85,6 +87,9 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete'),
),
],
@@ -163,12 +168,12 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
actions: [
if (widget.noteId != null)
IconButton(
icon: const Icon(Icons.delete_outline),
icon: const Icon(LucideIcons.trash2),
tooltip: 'Delete',
onPressed: _delete,
),
IconButton(
icon: Icon(_preview ? Icons.edit : Icons.preview),
icon: Icon(_preview ? LucideIcons.pencil : LucideIcons.eye),
tooltip: _preview ? 'Edit' : 'Preview',
onPressed: () => setState(() => _preview = !_preview),
),
@@ -179,7 +184,7 @@ class _NoteEditScreenState extends ConsumerState<NoteEditScreen> {
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
: const Icon(LucideIcons.check),
onPressed: _saving ? null : _save,
),
],
@@ -272,7 +277,7 @@ class _TagInput extends StatelessWidget {
label: Text('#$tag', style: const TextStyle(fontSize: 12)),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 4),
deleteIcon: const Icon(Icons.close, size: 14),
deleteIcon: const Icon(LucideIcons.x, size: 14),
onDeleted: () => onRemove(tag),
),
),
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -105,7 +106,7 @@ class _ProjectEditScreenState extends ConsumerState<ProjectEditScreen> {
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
: const Icon(LucideIcons.check),
onPressed: _saving ? null : _save,
),
],
+10 -2
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../data/local/database.dart';
import '../../data/models/project.dart';
import '../../providers/projects_provider.dart';
import '../../widgets/pending_sync_badge.dart';
class ProjectsScreen extends ConsumerWidget {
const ProjectsScreen({super.key});
@@ -30,7 +33,7 @@ class ProjectsScreen extends ConsumerWidget {
floatingActionButton: FloatingActionButton(
onPressed: () => context.push('/projects/new'),
tooltip: 'New project',
child: const Icon(Icons.add),
child: const Icon(LucideIcons.plus),
),
);
}
@@ -49,7 +52,12 @@ class _ProjectCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(project.title),
title: Row(
children: [
Flexible(child: Text(project.title)),
PendingSyncBadge(domain: kSyncDomainProjects, id: project.id),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+15 -14
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -24,28 +25,28 @@ class SettingsScreen extends ConsumerWidget {
ListTile(
title: const Text('Server URL'),
subtitle: Text(serverUrl ?? 'Not configured'),
leading: const Icon(Icons.dns),
leading: const Icon(LucideIcons.server),
onTap: () => context.go(Routes.setup),
),
const Divider(),
ListTile(
title: const Text('Appearance'),
leading: const Icon(Icons.brightness_6),
leading: const Icon(LucideIcons.sunMoon),
trailing: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.brightness_auto),
icon: Icon(LucideIcons.contrast),
tooltip: 'System',
),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
icon: Icon(LucideIcons.sun),
tooltip: 'Light',
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
icon: Icon(LucideIcons.moon),
tooltip: 'Dark',
),
],
@@ -58,7 +59,7 @@ class SettingsScreen extends ConsumerWidget {
// ── Updates ──────────────────────────────────────────────────────
ListTile(
leading: const Icon(Icons.update),
leading: const Icon(LucideIcons.refreshCw),
title: const Text('Update repository'),
subtitle: Text(repoUrl?.isNotEmpty == true
? repoUrl!
@@ -66,7 +67,7 @@ class SettingsScreen extends ConsumerWidget {
onTap: () => _editRepoUrl(context, ref, repoUrl),
),
ListTile(
leading: const Icon(Icons.info_outline),
leading: const Icon(LucideIcons.info),
title: const Text('App version'),
subtitle: _versionSubtitle(update),
trailing: update.status == UpdateStatus.checking
@@ -92,13 +93,13 @@ class SettingsScreen extends ConsumerWidget {
child: const Text('Check'),
),
),
if (update.status == UpdateStatus.available ||
if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading)
_UpdateTile(update: update),
if (update.status == UpdateStatus.error)
ListTile(
leading:
const Icon(Icons.error_outline, color: Colors.red),
const Icon(LucideIcons.alertCircle, color: Colors.red),
title: const Text('Update check failed'),
subtitle: Text(
update.errorMessage ?? 'Unknown error',
@@ -110,7 +111,7 @@ class SettingsScreen extends ConsumerWidget {
const Divider(),
ListTile(
title: const Text('Sign Out'),
leading: const Icon(Icons.logout),
leading: const Icon(LucideIcons.logOut),
onTap: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) context.go(Routes.login);
@@ -131,7 +132,7 @@ class SettingsScreen extends ConsumerWidget {
if (update.status == UpdateStatus.upToDate) {
return Text('v$current — up to date');
}
if (update.status == UpdateStatus.available ||
if (update.status == UpdateStatus.readyToInstall ||
update.status == UpdateStatus.downloading) {
return Text('v$current installed');
}
@@ -184,7 +185,7 @@ class _UpdateTile extends ConsumerWidget {
final isDownloading = update.status == UpdateStatus.downloading;
return ListTile(
leading: const Icon(Icons.system_update, color: Colors.green),
leading: const Icon(LucideIcons.downloadCloud, color: Colors.green),
title: Text('v${update.latestVersion} available'),
subtitle: isDownloading
? Column(
@@ -202,12 +203,12 @@ class _UpdateTile extends ConsumerWidget {
'${(update.downloadProgress * 100).toStringAsFixed(0)}%'),
],
)
: const Text('Tap to download and install'),
: const Text('Ready to install'),
trailing: isDownloading
? null
: FilledButton(
onPressed: () =>
ref.read(updateProvider.notifier).downloadAndInstall(),
ref.read(updateProvider.notifier).install(),
child: const Text('Install'),
),
);
+6 -1
View File
@@ -29,8 +29,13 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
await ref.read(authProvider.notifier).verify();
if (!mounted) return;
final status = ref.read(authProvider);
final hasEverLoggedIn = ref.read(hasEverLoggedInProvider);
if (status == AuthStatus.authenticated) {
context.go(Routes.briefing);
context.go(Routes.journal);
} else if (status == AuthStatus.offline && hasEverLoggedIn) {
// Server unreachable but this user has logged in before — land them on
// the briefing with the offline banner rather than the login screen.
context.go(Routes.journal);
} else {
context.go(Routes.login);
}
+10 -5
View File
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/constants.dart';
import '../../core/exceptions.dart';
import '../../core/theme.dart';
import '../../data/models/task.dart';
import '../../providers/api_client_provider.dart';
import '../../providers/tasks_provider.dart';
@@ -111,6 +113,9 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(dialogContext).extension<ActionColors>()!.destructive,
),
child: const Text('Delete')),
],
),
@@ -190,7 +195,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
actions: [
if (widget.taskId != null)
IconButton(
icon: const Icon(Icons.delete),
icon: const Icon(LucideIcons.trash2),
onPressed: _delete,
),
IconButton(
@@ -200,7 +205,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
: const Icon(LucideIcons.check),
onPressed: _saving ? null : _save,
),
],
@@ -272,10 +277,10 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
title: Text(_dueDate == null
? 'No due date'
: 'Due: ${_dueDate!.toLocal().toString().substring(0, 10)}'),
leading: const Icon(Icons.calendar_today),
leading: const Icon(LucideIcons.calendarDays),
trailing: _dueDate != null
? IconButton(
icon: const Icon(Icons.clear),
icon: const Icon(LucideIcons.x),
onPressed: () =>
setState(() => _dueDate = null),
)
@@ -331,7 +336,7 @@ class _SubTasksSection extends ConsumerWidget {
const Spacer(),
TextButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add'),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
+104 -8
View File
@@ -1,12 +1,17 @@
import 'dart:math' show min;
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/models/message.dart';
import '../providers/api_client_provider.dart';
import '../providers/settings_provider.dart';
import 'tool_call_chip.dart';
class ChatMessageBubble extends StatelessWidget {
class ChatMessageBubble extends ConsumerWidget {
final Message message;
final String streamingStatus;
const ChatMessageBubble({
@@ -16,9 +21,11 @@ class ChatMessageBubble extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final isUser = message.role == MessageRole.user;
final scheme = Theme.of(context).colorScheme;
final serverUrl = ref.watch(serverUrlProvider) ?? '';
final dio = ref.watch(dioProvider);
final isGenerating = message.status == 'generating';
final toolCalls = message.toolCalls ?? const [];
@@ -34,13 +41,16 @@ class ChatMessageBubble extends StatelessWidget {
maxWidth: min(MediaQuery.of(context).size.width * 0.82, 480),
),
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
// Illuminated Transcript pattern (mirrors web's ChatMessage.vue):
// - User bubble: transparent bg, neutral Pewter border, only the
// bottom-right corner clipped (the "from-me" tail).
// - Assistant bubble: card surface, 2px accent left edge (the
// "illuminated capital"), accent-tinted glow shadow + depth
// shadow, only the bottom-left corner clipped.
decoration: isUser
? BoxDecoration(
color: Colors.transparent,
border: Border.all(
color: scheme.primary.withValues(alpha: 0.35),
width: 1,
),
border: Border.all(color: scheme.outline, width: 1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
@@ -49,16 +59,28 @@ class ChatMessageBubble extends StatelessWidget {
),
)
: BoxDecoration(
color: scheme.surfaceContainerHighest,
color: scheme.surface,
border: Border(
left: BorderSide(color: scheme.primary, width: 2),
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
boxShadow: [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.14),
blurRadius: 28,
offset: const Offset(0, 4),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -116,6 +138,14 @@ class ChatMessageBubble extends StatelessWidget {
if (message.content.isNotEmpty)
MarkdownBody(
data: message.content,
imageBuilder: (uri, title, alt) {
return _AuthImage(
uri: uri,
alt: alt,
serverUrl: serverUrl,
dio: dio,
);
},
styleSheet: MarkdownStyleSheet(
p: TextStyle(
color: isUser
@@ -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:lucide_icons/lucide_icons.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/models/message.dart';
class BriefingDigestCard extends StatefulWidget {
/// The first assistant message from today's briefing, or null if none yet.
class JournalPrepCard extends StatefulWidget {
/// The first assistant message from today's journal — the daily prep
/// (LLM-generated briefing-style opener), or null if not yet generated.
final Message? message;
/// Called when the user taps "Generate now".
final VoidCallback? onGenerateNow;
const BriefingDigestCard({
const JournalPrepCard({
super.key,
required this.message,
this.onGenerateNow,
});
@override
State<BriefingDigestCard> createState() => _BriefingDigestCardState();
State<JournalPrepCard> createState() => _JournalPrepCardState();
}
class _BriefingDigestCardState extends State<BriefingDigestCard> {
class _JournalPrepCardState extends State<JournalPrepCard> {
bool _expanded = false;
@override
@@ -43,10 +45,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Row(
children: [
Icon(Icons.wb_sunny_outlined, size: 18, color: scheme.primary),
Icon(LucideIcons.bookOpen, size: 18, color: scheme.primary),
const SizedBox(width: 8),
Text(
_todayLabel(),
@@ -57,11 +58,9 @@ class _BriefingDigestCardState extends State<BriefingDigestCard> {
],
),
const SizedBox(height: 10),
// Body
if (widget.message == null) ...[
Text(
'No briefing yet today.',
'No prep yet today.',
style: textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
+23 -9
View File
@@ -1,18 +1,24 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../data/local/database.dart';
import '../data/models/knowledge_item.dart';
import 'pending_sync_badge.dart';
class KnowledgeItemCard extends StatelessWidget {
final KnowledgeItem item;
const KnowledgeItemCard({super.key, required this.item});
String get _pendingDomain =>
item.noteType == 'task' ? kSyncDomainTasks : kSyncDomainNotes;
IconData get _icon => switch (item.noteType) {
'person' => Icons.person_outlined,
'place' => Icons.place_outlined,
'list' => Icons.checklist_outlined,
'task' => Icons.task_alt_outlined,
_ => Icons.description_outlined,
'person' => LucideIcons.user,
'place' => LucideIcons.mapPin,
'list' => LucideIcons.listChecks,
'task' => LucideIcons.checkCircle2,
_ => LucideIcons.fileText,
};
Color _statusColor(BuildContext context) {
@@ -51,10 +57,17 @@ class KnowledgeItemCard extends StatelessWidget {
Widget build(BuildContext context) {
return ListTile(
leading: Icon(_icon, color: _statusColor(context)),
title: Text(
item.title.isEmpty ? '(untitled)' : item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
title: Row(
children: [
Flexible(
child: Text(
item.title.isEmpty ? '(untitled)' : item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
PendingSyncBadge(domain: _pendingDomain, id: item.id),
],
),
subtitle: _subtitle != null
? Text(
@@ -99,6 +112,7 @@ class KnowledgeItemCard extends StatelessWidget {
?.copyWith(fontWeight: FontWeight.w600),
),
),
PendingSyncBadge(domain: _pendingDomain, id: item.id),
],
),
if (_subtitle != null) ...[
-234
View File
@@ -1,234 +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;
final int snippetMaxLines;
const NewsCard({
super.key,
required this.item,
required this.reaction,
required this.onReaction,
this.onDiscuss,
this.snippetMaxLines = 2,
});
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: snippetMaxLines,
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:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../core/constants.dart';
@@ -45,24 +46,24 @@ const Map<String, String> _toolLabels = {
};
IconData _iconFor(String fn) {
if (fn.contains('note')) return Icons.sticky_note_2_outlined;
if (fn.contains('task')) return Icons.check_circle_outline;
if (fn.contains('note')) return LucideIcons.stickyNote;
if (fn.contains('task')) return LucideIcons.checkCircle2;
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('milestone')) return Icons.flag_outlined;
if (fn.contains('project')) return LucideIcons.folder;
if (fn.contains('milestone')) return LucideIcons.flag;
if (fn.contains('web') || fn.contains('research') || fn.contains('article')) {
return 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')) {
return Icons.person_outline;
return LucideIcons.user;
}
if (fn.contains('place')) return Icons.place_outlined;
if (fn.contains('rag') || fn.contains('scope')) return Icons.tune;
if (fn.contains('calculate')) return Icons.calculate_outlined;
return Icons.auto_awesome;
if (fn.contains('place')) return LucideIcons.mapPin;
if (fn.contains('rag') || fn.contains('scope')) return LucideIcons.sliders;
if (fn.contains('calculate')) return LucideIcons.calculator;
return LucideIcons.sparkles;
}
/// Pull the destination route for this tool call from its `result` payload,
@@ -161,7 +162,7 @@ class ToolCallChip extends StatelessWidget {
),
if (route != null) ...[
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:lucide_icons/lucide_icons.dart';
import '../providers/voice_provider.dart';
@@ -48,8 +49,8 @@ class VoiceMicButton extends StatelessWidget {
color: iconColor,
),
),
VoiceMode.playing => Icon(Icons.volume_up, color: iconColor, size: 20),
_ => Icon(Icons.mic, color: iconColor, size: 20),
VoiceMode.playing => Icon(LucideIcons.volume2, 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 <open_file_linux/open_file_linux_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
@@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
+2
View File
@@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_timezone
open_file_linux
record_linux
sqlite3_flutter_libs
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
)
set(PLUGIN_BUNDLED_LIBRARIES)
@@ -11,8 +11,9 @@ import flutter_timezone
import just_audio
import open_file_mac
import package_info_plus
import record_darwin
import record_macos
import shared_preferences_foundation
import sqlite3_flutter_libs
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -22,7 +23,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
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"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+176 -8
View File
@@ -57,6 +57,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
url: "https://pub.dev"
source: hosted
version: "4.0.6"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "22fdcc3cfeb9d974d7408718c4be15ec5e9b1b350088f3a6c88f154e74dd700d"
url: "https://pub.dev"
source: hosted
version: "2.14.1"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev"
source: hosted
version: "8.12.5"
characters:
dependency: transitive
description:
@@ -65,6 +113,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -153,6 +209,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
url: "https://pub.dev"
source: hosted
version: "3.1.7"
dio:
dependency: "direct main"
description:
@@ -177,6 +241,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
drift:
dependency: "direct main"
description:
name: drift
sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5"
url: "https://pub.dev"
source: hosted
version: "2.32.1"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91"
url: "https://pub.dev"
source: hosted
version: "2.32.1"
equatable:
dependency: transitive
description:
@@ -368,6 +448,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.0.2"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hooks:
dependency: transitive
description:
@@ -496,6 +584,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
lucide_icons:
dependency: "direct main"
description:
name: lucide_icons
sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4
url: "https://pub.dev"
source: hosted
version: "0.257.0"
markdown:
dependency: "direct main"
description:
@@ -649,7 +745,7 @@ packages:
source: hosted
version: "3.2.1"
path:
dependency: transitive
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
@@ -800,14 +896,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record:
dependency: "direct main"
description:
name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev"
source: hosted
version: "5.2.1"
version: "6.2.0"
record_android:
dependency: transitive
description:
@@ -816,22 +928,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_darwin:
record_ios:
dependency: transitive
description:
name: record_darwin
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
name: record_ios
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.2.0"
record_linux:
dependency: "direct overridden"
dependency: transitive
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface:
dependency: transitive
description:
@@ -973,6 +1093,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02
url: "https://pub.dev"
source: hosted
version: "4.2.3"
source_map_stack_trace:
dependency: transitive
description:
@@ -997,6 +1125,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
url: "https://pub.dev"
source: hosted
version: "0.5.42"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b
url: "https://pub.dev"
source: hosted
version: "0.44.3"
stack_trace:
dependency: transitive
description:
@@ -1021,6 +1173,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -1165,6 +1325,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vad:
dependency: "direct main"
description:
name: vad
sha256: ef6c8b12c5af7a6a519ff5684f074b8a2ac00c434705f544af379ea77bccd258
url: "https://pub.dev"
source: hosted
version: "0.0.7+1"
vector_math:
dependency: transitive
description:
+11 -3
View File
@@ -12,6 +12,7 @@ dependencies:
sdk: flutter
cupertino_icons: ^1.0.8
lucide_icons: ^0.257.0
flutter_riverpod: ^3.3.1
go_router: ^17.1.0
dio: ^5.6.0
@@ -28,18 +29,25 @@ dependencies:
google_fonts: ^8.0.2
flutter_timezone: ^5.0.2
url_launcher: ^6.3.1
record: ^5.0.0
record: ^6.2.0
vad: ^0.0.7
just_audio: ^0.9.39
table_calendar: ^3.1.2
dependency_overrides:
record_linux: ^1.3.0
# Tier 2 offline mode — local SQL store via Drift (sqlite3 under the hood).
# Drift was preferred over Hive in the design since the repo already mirrors
# well-defined backend schemas (notes/tasks/projects/etc.) one-to-one.
drift: ^2.20.0
sqlite3_flutter_libs: ^0.5.24
path: ^1.9.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.3
drift_dev: ^2.20.0
build_runner: ^2.4.13
flutter_launcher_icons:
android: true
+29 -65
View File
@@ -1,10 +1,9 @@
import 'package:fabled_app/data/models/calendar_event.dart';
import 'package:fabled_app/data/api/voice_api.dart';
import 'package:fabled_app/data/repositories/write_queue.dart';
import 'package:fabled_app/providers/voice_provider.dart';
import 'package:fabled_app/data/models/knowledge_item.dart';
import 'package:fabled_app/data/models/note.dart';
import 'package:fabled_app/data/models/news_item.dart';
import 'package:fabled_app/data/models/briefing_feed.dart';
import 'package:flutter_test/flutter_test.dart';
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', () {
test('parses all fields', () {
final json = {
@@ -250,30 +208,36 @@ void main() {
});
});
group('BriefingFeed.fromJson', () {
test('parses all fields', () {
final json = {
'id': 7,
'title': 'Hacker News',
'url': 'https://news.ycombinator.com/rss',
'category': 'tech',
};
final feed = BriefingFeed.fromJson(json);
expect(feed.id, equals(7));
expect(feed.title, equals('Hacker News'));
expect(feed.url, equals('https://news.ycombinator.com/rss'));
expect(feed.category, equals('tech'));
group('QueueFailure.message', () {
test('overwritten with title quotes the title', () {
final f = QueueFailure(
reason: QueueFailureReason.overwritten,
domain: 'notes',
title: 'Grocery list',
);
expect(f.message, contains('"Grocery list"'));
expect(f.message, contains('overwritten'));
});
test('handles null category', () {
final json = {
'id': 8,
'title': 'Feed',
'url': 'https://example.com/rss',
'category': null,
};
final feed = BriefingFeed.fromJson(json);
expect(feed.category, isNull);
test('rejected without title falls back to generic phrasing', () {
final f = QueueFailure(
reason: QueueFailureReason.rejected,
domain: 'tasks',
detail: 'title required',
);
expect(f.message, contains('an offline edit'));
expect(f.message, contains('title required'));
});
test('missing communicates server-side deletion', () {
final f = QueueFailure(
reason: QueueFailureReason.missing,
domain: 'projects',
title: 'Q2 launch',
);
expect(f.message, contains('"Q2 launch"'));
expect(f.message, contains('deleted on the server'));
});
});
}
@@ -10,6 +10,7 @@
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
+2
View File
@@ -7,10 +7,12 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_timezone
permission_handler_windows
record_windows
sqlite3_flutter_libs
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
vad
)
set(PLUGIN_BUNDLED_LIBRARIES)