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>
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>
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>
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>
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).
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>
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>
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>
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>
The previous -40 dBFS static threshold sat right on top of typical
phone mic ambient, so silence detection rarely fired and the user
always had to tap stop manually.
Silence threshold is now dynamic: track the session peak dBFS and
treat "silent" as 15 dB below peak. Auto-calibrates per mic and
environment rather than assuming a fixed ambient level.
- Grace period (1500 ms) at start so the user has time to begin
speaking before checks arm.
- Static -35 dB fallback until the peak clears -20 dB so a dead-
silent session doesn't spin forever.
- Silence duration bumped 1500 → 2000 ms for breathing room.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VoiceState now carries a normalized mic amplitude (0..1) updated
from the existing onAmplitudeChanged subscription, with a 0.02
change threshold so we don't spam rebuilds.
VoiceMicButton swaps the constant-rate AnimationController for an
AnimatedScale + animated glow driven by the live amplitude. 0.1
floor keeps the button breathing on silence; 120ms ease-out smooths
between 200ms samples.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Briefing chat now uses the same StreamIterator + 45s per-event timeout
as the main chat provider, so a dropped SSE socket no longer leaves
isBriefingStreaming stuck true. Adds refreshMessages() that unfreezes
state when the server-side message is complete, wired to a new
pull-to-refresh gesture and the app lifecycle-resume hook.
Chat provider gains attachToGeneration() — safe to call unconditionally
on screen init, so landing on a conversation that's already mid-stream
(e.g. the /news discuss button, which now creates a conv and auto-kicks
generation server-side) picks up live tokens instead of freezing on the
placeholder.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mobile chat occasionally froze with the input disabled and nothing
progressing — the underlying cause is SSE sockets dropping silently on
mobile network handoffs / proxy idle timeouts. Dio never observes the
close, so the send loop's `await for` hangs indefinitely with
isStreaming=true.
Three changes:
- Wrap the SSE stream in a StreamIterator with a 45s per-event timeout
as a stall watchdog. On timeout, break out and let the polling pass
reconcile state from the server.
- Add pull-to-refresh on the chat message list and an AppBar refresh
button with inline spinner feedback. Works on both empty and
populated states.
- MessagesNotifier.refresh() now also clears isStreaming /
streamingStatus when the server reports the latest assistant message
as complete — so a stuck UI unfreezes the moment the user pulls down
or taps refresh, even if the original SSE loop is still hanging.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The mobile app was receiving SSE status events but showing the peek text
only while the assistant bubble was empty — as soon as the first token
arrived, the status line was replaced by streaming content and any tool
calls fired mid-turn left no trace. Tool call SSE events were also being
dropped by the parser, and Message.fromJson never read the persisted
tool_calls array, so chips never rendered after reload either.
Parse tool_call SSE frames into a new ChatToolCall event, carry tool
calls on Message, and update the chat and briefing streaming loops to
append chips to the in-flight assistant message as they arrive. Rework
ChatMessageBubble to show a chip row + rolling peek status line above
any streamed text, matching the web ToolCallCard/status indicator
behaviour across chat, briefing, and briefing history surfaces.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Voice: recreate AudioRecorder each session to avoid stale native
state, improve permission handling (re-check after settings), show
feedback after 3 consecutive empty transcripts, allow STT-only mode
when TTS is unavailable.
Knowledge: hydrate IDs immediately after loading more pages so
scroll-based pagination doesn't stall at the bottom.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Offline queue (SharedPreferences) persists captures when the device
is offline and replays them as chat conversations on next launch,
preserving the same fire-and-forget guarantee as the online path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace POST /api/quick-capture with: create a conversation, send the
message, and fire-and-forget the SSE generation stream so the assistant
processes the request in the background without blocking the UI.
The new conversation appears immediately in the chat tab.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three bugs causing silent STT failure on Android:
1. AudioEncoder.opus produces an OGG container on Android but the file
was named .webm — faster-whisper rejected it due to format mismatch.
Changed to AudioEncoder.aacLc + .m4a (reliable on all Android versions).
Updated voice_api.dart to send audio/mp4 MIME type accordingly.
2. onError callback was never stored, so errors in _startListening() and
_handleSilence() were silently swallowed. Now stored as _onError and
called before exitVoiceMode() on any failure.
3. Amplitude stream can emit NaN or ±Infinity on some Android devices
during recorder initialisation. NaN < -40.0 is false, so silence was
never detected. Now treated explicitly as silence.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tasks are not part of the knowledge API (backend rejects type=task).
When the Tasks tab is selected, fetch from /api/tasks and convert to
KnowledgeItem. Also removes per-type counts from tab labels to prevent
tabs from awkwardly resizing when counts load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parse SSE `status` events alongside `chunk` events and display the
status text next to the spinner while the assistant is generating.
Previously the Android app discarded all non-chunk SSE events.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Timer.periodic every 60s calls silentRefresh() on BriefingNotifier.
silentRefresh() patches AsyncData directly — never triggers AsyncLoading —
so existing content stays on screen while the fetch is in flight.
WidgetsBindingObserver pauses polling when app is backgrounded.
Polling is also skipped while a reply is actively streaming.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On shell mount, reads the IANA timezone from the device via
flutter_timezone and POSTs it to PUT /api/settings as user_timezone.
Mirrors the web app's App.vue behaviour so briefing events and the
chat route use the correct local time on Android.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Android 8+ requires canRequestPackageInstalls() to return true even when
REQUEST_INSTALL_PACKAGES is declared in the manifest. Add permission_handler
and check/request Permission.requestInstallPackages before downloading;
redirects user to Settings if not granted and shows a clear error message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ProjectLibraryCard.onTap navigates to /projects/:id/tasks
- New ProjectTasksScreen: milestone sections with coloured dot,
done/total counter and thin progress bar; unassigned tasks at bottom
- Status cycle icon updates optimistically (pendingStatus map) so
the list doesn't flash on every tap; syncs global tasksProvider
- New route /projects/:id/tasks registered in app.dart
- TasksApi.getByProject + TasksRepository + projectTasksProvider (family)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Check OpenResult.type before resetting state — previously a failed
open_file call (e.g. installer blocked on emulator) silently called
state = const UpdateState(), nulling latestVersion and downloadUrl,
causing "Version null" and a non-functional Download button
- Show errorMessage in dialog with error colour so failures are visible
- Guard Download button on downloadUrl != null (no button if URL lost)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migrate StateNotifierProvider/StateNotifier → NotifierProvider/Notifier,
StateProvider → NotifierProvider with simple notifier, FamilyAsyncNotifier
removed in Riverpod 3 (replaced with constructor-arg pattern), and rename
.valueOrNull → .value throughout all providers and screens.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After OpenFile.open() the state was reset to UpdateStatus.available,
which re-triggered the app.dart listener (downloading → available
satisfies the prev != available condition) and showed the dialog again.
Fix: reset to idle so the system installer can proceed uninterrupted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Milestone data layer:
- lib/data/models/milestone.dart: Milestone model with progress fields
(total, completed, pct) from backend progress endpoint
- lib/data/api/milestones_api.dart: CRUD client for
/api/projects/:id/milestones
- lib/data/repositories/milestones_repository.dart: thin repository wrapper
- lib/providers/milestones_provider.dart: FutureProvider.family keyed by
project ID — lazily fetched per project when expanded
- api_client_provider.dart: registers milestonesApiProvider and
milestonesRepositoryProvider
Projects tab redesign (project_list_screen.dart):
- Each project is now an ExpansionTile showing open task count in subtitle
- Expanding a project fetches its milestones and groups tasks under milestone
headers with inline progress bar (completed/total count)
- Tasks show status icon, due date with overdue highlighting, priority dot
- 'No milestone' section at bottom for unassigned tasks
- Tapping a task navigates to the task editor
- Long-press on project title opens archive/complete/delete bottom sheet
- 'New task' button at bottom of each expanded project section
Bump version to 26.03.02+1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pubspec: 26.03.01+1 (Flutter uses + to separate versionName/versionCode)
Update checker combines version + buildNumber → "26.03.01.1" for
comparison against Forgejo release tags in the same format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The app now checks for new APK releases on a Forgejo instance and can
download and install them without leaving the app.
Settings:
- New "Update repository URL" field (e.g. https://git.example.com/user/fabled_app)
- "App version" tile with a manual "Check" button and live status display
- Download progress bar and "Install" button appear when update is found
Startup:
- Shell runs a silent background check on first load when a repo URL is set
- A dialog appears automatically if a newer release is found
Provider (update_provider.dart):
- UpdateNotifier: idle → checking → available → downloading → available
- Hits Forgejo /api/v1/repos/{owner}/{repo}/releases/latest
- Parses semver tag, finds .apk asset, downloads with progress via Dio
- Opens the APK with open_file to trigger the system package installer
Android:
- REQUEST_INSTALL_PACKAGES permission
- FileProvider configured for open_file (shares APK URI with installer)
- res/xml/file_paths.xml covers external-files and cache directories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: when SSE delivered any chunk, maxAttempts was set to 1 with
no delay. That single immediate poll hit the server before it finished
writing to the DB, received status='generating' with empty content, then
overwrote the SSE-streamed text with the stale empty row and exited.
isStreaming went false and the UI was stuck showing '...'.
Two fixes:
1. Always poll up to 20 times regardless of SSE success. SSE closing
does not mean the server has committed the final row to the DB.
2. When SSE delivered content, only replace state with polled data if
the polled assistant message is complete or has content — this
prevents clobbering streamed text with a stale empty DB row while
the server is still writing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Edit screens (note, task): cache _initFuture in initState instead of
calling _loadExisting() from FutureBuilder on every rebuild. Prevents
race condition where multiple concurrent loads could overwrite edits.
Also removes unnecessary _loaded flag and simplifies the pattern.
- Note editor: remove onChanged: (_) => setState((){}) on the body
TextField — triggered a full rebuild on every keystroke for no reason.
- SSE streaming: use utf8.decode(chunk, allowMalformed: true) so a
malformed byte sequence from the server skips rather than throwing an
unhandled FormatException that escaped all catch blocks.
- Chat provider: guard msgs.isEmpty before accessing msgs.last during
SSE chunk processing to prevent a potential index error on empty state.
- Offline queue drain: check mounted before and after each async gap in
_drainQueue so ref is never accessed on a disposed widget.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>