This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/providers/api_client_provider.dart
T
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

176 lines
5.4 KiB
Dart

import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/api/api_client.dart';
import '../data/api/auth_api.dart';
import '../data/api/chat_api.dart';
import '../data/api/journal_api.dart';
import '../data/api/knowledge_api.dart';
import '../data/api/voice_api.dart';
import '../data/api/milestones_api.dart';
import '../data/api/events_api.dart';
import '../data/api/notes_api.dart';
import '../data/api/projects_api.dart';
import '../data/api/quick_capture_api.dart';
import '../data/api/settings_api.dart';
import '../data/api/tasks_api.dart';
import '../data/local/database.dart';
import '../data/repositories/auth_repository.dart';
import '../data/repositories/chat_repository.dart';
import '../data/repositories/events_repository.dart';
import '../data/repositories/knowledge_repository.dart';
import '../data/repositories/voice_repository.dart';
import '../data/repositories/milestones_repository.dart';
import '../data/repositories/notes_repository.dart';
import '../data/repositories/projects_repository.dart';
import '../data/repositories/tasks_repository.dart';
import '../data/repositories/write_queue.dart';
import 'settings_provider.dart';
final cookieJarProvider = Provider<PersistCookieJar>((ref) {
final cookiesPath = ref.watch(cookiesPathProvider);
return buildCookieJar(cookiesPath);
});
final dioProvider = Provider<Dio>((ref) {
final url = ref.watch(serverUrlProvider) ?? '';
final jar = ref.watch(cookieJarProvider);
return buildDio(url, jar);
});
final authApiProvider = Provider<AuthApi>((ref) {
return AuthApi(ref.watch(dioProvider));
});
final notesApiProvider = Provider<NotesApi>((ref) {
return NotesApi(ref.watch(dioProvider));
});
final tasksApiProvider = Provider<TasksApi>((ref) {
return TasksApi(ref.watch(dioProvider));
});
final chatApiProvider = Provider<ChatApi>((ref) {
return ChatApi(ref.watch(dioProvider));
});
final quickCaptureApiProvider = Provider<QuickCaptureApi>((ref) {
return QuickCaptureApi(ref.watch(dioProvider));
});
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),
ref.watch(fabledDatabaseProvider),
);
});
final tasksRepositoryProvider = Provider<TasksRepository>((ref) {
return TasksRepository(
ref.watch(tasksApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
return ChatRepository(
ref.watch(chatApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final projectsRepositoryProvider = Provider<ProjectsRepository>((ref) {
return ProjectsRepository(
ref.watch(projectsApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final milestonesApiProvider = Provider<MilestonesApi>((ref) {
return MilestonesApi(ref.watch(dioProvider));
});
final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
return MilestonesRepository(
ref.watch(milestonesApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
final knowledgeApiProvider = Provider<KnowledgeApi>((ref) {
return KnowledgeApi(ref.watch(dioProvider));
});
final knowledgeRepositoryProvider = Provider<KnowledgeRepository>((ref) {
return KnowledgeRepository(ref.watch(knowledgeApiProvider));
});
final journalApiProvider = Provider<JournalApi>((ref) {
return JournalApi(ref.watch(dioProvider));
});
final settingsApiProvider = Provider<SettingsApi>((ref) {
return SettingsApi(ref.watch(dioProvider));
});
final voiceApiProvider = Provider<VoiceApi>((ref) {
return VoiceApi(ref.watch(dioProvider));
});
final voiceRepositoryProvider = Provider<VoiceRepository>((ref) {
return VoiceRepository(ref.watch(voiceApiProvider));
});
final eventsApiProvider = Provider<EventsApi>((ref) {
return EventsApi(ref.watch(dioProvider));
});
final eventsRepositoryProvider = Provider<EventsRepository>((ref) {
return EventsRepository(
ref.watch(eventsApiProvider),
ref.watch(fabledDatabaseProvider),
);
});
/// Phase 3 — drains the offline write queue. Listen on
/// `writeQueueFailuresProvider` to surface dropped ops to the user;
/// `writeQueueDepthProvider` powers the OfflineBanner's "Retry (N)" affordance.
final writeQueueProvider = Provider<WriteQueue>((ref) {
final queue = WriteQueue(
db: ref.watch(fabledDatabaseProvider),
notesApi: ref.watch(notesApiProvider),
tasksApi: ref.watch(tasksApiProvider),
projectsApi: ref.watch(projectsApiProvider),
milestonesApi: ref.watch(milestonesApiProvider),
eventsApi: ref.watch(eventsApiProvider),
);
ref.onDispose(queue.dispose);
return queue;
});
final writeQueueDepthProvider = StreamProvider<int>((ref) {
return ref.watch(fabledDatabaseProvider).watchQueueDepth();
});
final writeQueueFailuresProvider = StreamProvider<QueueFailure>((ref) {
return ref.watch(writeQueueProvider).failures;
});