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 _failures = StreamController.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 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 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 _replayOne(PendingWrite op) async { final payload = jsonDecode(op.payloadJson) as Map; 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 _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 _replayNote(PendingWrite op, Map 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( 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 _replayTask(PendingWrite op, Map 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( 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 _replayProject(PendingWrite op, Map 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( 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 _replayMilestone( PendingWrite op, Map 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.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 _replayEvent(PendingWrite op, Map 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 _conflictGuard({ required DateTime? baseline, required Future Function() fetch, required DateTime Function(T) updatedAt, required String Function(T) title, required Future 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 _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); }