From 7df7b5ff85525cab0b4bc2fc3d120b7b31f10996 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Tue, 28 Apr 2026 22:08:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(offline):=20tier=202=20phase=203=20?= =?UTF-8?q?=E2=80=94=20write=20queue=20with=20optimistic=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/app.dart | 29 + lib/core/exceptions.dart | 9 + lib/data/api/api_client.dart | 16 + lib/data/local/database.dart | 127 ++- lib/data/local/database.g.dart | 902 ++++++++++++++++++ lib/data/repositories/events_repository.dart | 123 ++- .../repositories/milestones_repository.dart | 127 ++- lib/data/repositories/notes_repository.dart | 144 ++- .../repositories/projects_repository.dart | 113 ++- lib/data/repositories/tasks_repository.dart | 138 ++- lib/data/repositories/write_queue.dart | 385 ++++++++ lib/providers/api_client_provider.dart | 25 + lib/widgets/offline_banner.dart | 12 +- test/widget_test.dart | 33 + 14 files changed, 2090 insertions(+), 93 deletions(-) create mode 100644 lib/data/repositories/write_queue.dart diff --git a/lib/app.dart b/lib/app.dart index 05c727a..fe61fed 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -7,6 +7,7 @@ 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'; @@ -331,6 +332,17 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { ); } + void _showQueueFailureSnackbar(QueueFailure failure) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(failure.message), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 6), + ), + ); + } + @override Widget build(BuildContext context) { // Show update dialog once when a new version is detected. @@ -341,6 +353,23 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver { .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, tabs); diff --git a/lib/core/exceptions.dart b/lib/core/exceptions.dart index a64fd1c..0daabb5 100644 --- a/lib/core/exceptions.dart +++ b/lib/core/exceptions.dart @@ -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); +} diff --git a/lib/data/api/api_client.dart b/lib/data/api/api_client.dart index e88b917..098eeda 100644 --- a/lib/data/api/api_client.dart +++ b/lib/data/api/api_client.dart @@ -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) { + for (final key in const ['detail', 'error', 'message']) { + final value = data[key]; + if (value is String && value.isNotEmpty) return value; + } + } + return null; +} diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart index de84957..fe4f01e 100644 --- a/lib/data/local/database.dart +++ b/lib/data/local/database.dart @@ -136,6 +136,29 @@ class SyncMetadata extends Table { Set 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'; @@ -143,6 +166,10 @@ 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, @@ -151,13 +178,14 @@ const String kSyncDomainConversations = 'conversations'; CachedCalendarEvents, CachedConversations, SyncMetadata, + PendingWrites, ]) class FabledDatabase extends _$FabledDatabase { FabledDatabase() : super(_openConnection()); FabledDatabase.forTesting(super.executor); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -171,6 +199,10 @@ class FabledDatabase extends _$FabledDatabase { await m.createTable(cachedCalendarEvents); await m.createTable(cachedConversations); } + // v2 → v3: added the offline write queue. + if (from < 3) { + await m.createTable(pendingWrites); + } }, ); @@ -448,6 +480,99 @@ class FabledDatabase extends _$FabledDatabase { ), ); } + + // ── 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 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('m'); + return (current ?? 0) - 1; + } + + Future enqueuePending({ + required String domain, + required String verb, + int? targetId, + int? tempId, + Map 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 findQueuedCreate(String domain, int tempId) { + return (select(pendingWrites) + ..where((t) => + t.domain.equals(domain) & + t.verb.equals(kWriteVerbCreate) & + t.tempId.equals(tempId))) + .getSingleOrNull(); + } + + Future updatePendingPayload(int opId, Map payload) { + return (update(pendingWrites)..where((t) => t.id.equals(opId))).write( + PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))), + ); + } + + Future deletePending(int opId) { + return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go(); + } + + /// Pending ops oldest first — replay order. + Future> listPending() { + return (select(pendingWrites) + ..orderBy([(t) => OrderingTerm.asc(t.id)])) + .get(); + } + + Future queueDepth() async { + final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes') + .getSingle(); + return row.read('c'); + } + + Future markPendingFailed(int opId, String error) { + return (update(pendingWrites)..where((t) => t.id.equals(opId))).write( + PendingWritesCompanion( + tries: const Value.absent(), + lastError: Value(error), + ), + ); + } + + Future 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 watchQueueDepth() { + final query = customSelect( + 'SELECT COUNT(*) AS c FROM pending_writes', + readsFrom: {pendingWrites}, + ); + return query.watchSingle().map((row) => row.read('c')); + } } // ── Row ↔ model converters ────────────────────────────────────────────────── diff --git a/lib/data/local/database.g.dart b/lib/data/local/database.g.dart index 2598bca..3ee62cf 100644 --- a/lib/data/local/database.g.dart +++ b/lib/data/local/database.g.dart @@ -3831,6 +3831,609 @@ class SyncMetadataCompanion extends UpdateCompanion { } } +class $PendingWritesTable extends PendingWrites + with TableInfo<$PendingWritesTable, PendingWrite> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PendingWritesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + static const VerificationMeta _domainMeta = const VerificationMeta('domain'); + @override + late final GeneratedColumn domain = GeneratedColumn( + 'domain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _verbMeta = const VerificationMeta('verb'); + @override + late final GeneratedColumn verb = GeneratedColumn( + 'verb', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _targetIdMeta = const VerificationMeta( + 'targetId', + ); + @override + late final GeneratedColumn targetId = GeneratedColumn( + 'target_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _tempIdMeta = const VerificationMeta('tempId'); + @override + late final GeneratedColumn tempId = GeneratedColumn( + 'temp_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _payloadJsonMeta = const VerificationMeta( + 'payloadJson', + ); + @override + late final GeneratedColumn payloadJson = GeneratedColumn( + 'payload_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('{}'), + ); + static const VerificationMeta _baselineUpdatedAtMeta = const VerificationMeta( + 'baselineUpdatedAt', + ); + @override + late final GeneratedColumn baselineUpdatedAt = + GeneratedColumn( + 'baseline_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + static const VerificationMeta _triesMeta = const VerificationMeta('tries'); + @override + late final GeneratedColumn tries = GeneratedColumn( + 'tries', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _lastErrorMeta = const VerificationMeta( + 'lastError', + ); + @override + late final GeneratedColumn lastError = GeneratedColumn( + 'last_error', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + domain, + verb, + targetId, + tempId, + payloadJson, + baselineUpdatedAt, + createdAt, + tries, + lastError, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'pending_writes'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('domain')) { + context.handle( + _domainMeta, + domain.isAcceptableOrUnknown(data['domain']!, _domainMeta), + ); + } else if (isInserting) { + context.missing(_domainMeta); + } + if (data.containsKey('verb')) { + context.handle( + _verbMeta, + verb.isAcceptableOrUnknown(data['verb']!, _verbMeta), + ); + } else if (isInserting) { + context.missing(_verbMeta); + } + if (data.containsKey('target_id')) { + context.handle( + _targetIdMeta, + targetId.isAcceptableOrUnknown(data['target_id']!, _targetIdMeta), + ); + } + if (data.containsKey('temp_id')) { + context.handle( + _tempIdMeta, + tempId.isAcceptableOrUnknown(data['temp_id']!, _tempIdMeta), + ); + } + if (data.containsKey('payload_json')) { + context.handle( + _payloadJsonMeta, + payloadJson.isAcceptableOrUnknown( + data['payload_json']!, + _payloadJsonMeta, + ), + ); + } + if (data.containsKey('baseline_updated_at')) { + context.handle( + _baselineUpdatedAtMeta, + baselineUpdatedAt.isAcceptableOrUnknown( + data['baseline_updated_at']!, + _baselineUpdatedAtMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + if (data.containsKey('tries')) { + context.handle( + _triesMeta, + tries.isAcceptableOrUnknown(data['tries']!, _triesMeta), + ); + } + if (data.containsKey('last_error')) { + context.handle( + _lastErrorMeta, + lastError.isAcceptableOrUnknown(data['last_error']!, _lastErrorMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PendingWrite map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PendingWrite( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + domain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}domain'], + )!, + verb: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}verb'], + )!, + targetId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}target_id'], + ), + tempId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}temp_id'], + ), + payloadJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}payload_json'], + )!, + baselineUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}baseline_updated_at'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + tries: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}tries'], + )!, + lastError: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_error'], + ), + ); + } + + @override + $PendingWritesTable createAlias(String alias) { + return $PendingWritesTable(attachedDatabase, alias); + } +} + +class PendingWrite extends DataClass implements Insertable { + final int id; + final String domain; + final String verb; + final int? targetId; + final int? tempId; + final String payloadJson; + final DateTime? baselineUpdatedAt; + final DateTime createdAt; + final int tries; + final String? lastError; + const PendingWrite({ + required this.id, + required this.domain, + required this.verb, + this.targetId, + this.tempId, + required this.payloadJson, + this.baselineUpdatedAt, + required this.createdAt, + required this.tries, + this.lastError, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['domain'] = Variable(domain); + map['verb'] = Variable(verb); + if (!nullToAbsent || targetId != null) { + map['target_id'] = Variable(targetId); + } + if (!nullToAbsent || tempId != null) { + map['temp_id'] = Variable(tempId); + } + map['payload_json'] = Variable(payloadJson); + if (!nullToAbsent || baselineUpdatedAt != null) { + map['baseline_updated_at'] = Variable(baselineUpdatedAt); + } + map['created_at'] = Variable(createdAt); + map['tries'] = Variable(tries); + if (!nullToAbsent || lastError != null) { + map['last_error'] = Variable(lastError); + } + return map; + } + + PendingWritesCompanion toCompanion(bool nullToAbsent) { + return PendingWritesCompanion( + id: Value(id), + domain: Value(domain), + verb: Value(verb), + targetId: targetId == null && nullToAbsent + ? const Value.absent() + : Value(targetId), + tempId: tempId == null && nullToAbsent + ? const Value.absent() + : Value(tempId), + payloadJson: Value(payloadJson), + baselineUpdatedAt: baselineUpdatedAt == null && nullToAbsent + ? const Value.absent() + : Value(baselineUpdatedAt), + createdAt: Value(createdAt), + tries: Value(tries), + lastError: lastError == null && nullToAbsent + ? const Value.absent() + : Value(lastError), + ); + } + + factory PendingWrite.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PendingWrite( + id: serializer.fromJson(json['id']), + domain: serializer.fromJson(json['domain']), + verb: serializer.fromJson(json['verb']), + targetId: serializer.fromJson(json['targetId']), + tempId: serializer.fromJson(json['tempId']), + payloadJson: serializer.fromJson(json['payloadJson']), + baselineUpdatedAt: serializer.fromJson( + json['baselineUpdatedAt'], + ), + createdAt: serializer.fromJson(json['createdAt']), + tries: serializer.fromJson(json['tries']), + lastError: serializer.fromJson(json['lastError']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'domain': serializer.toJson(domain), + 'verb': serializer.toJson(verb), + 'targetId': serializer.toJson(targetId), + 'tempId': serializer.toJson(tempId), + 'payloadJson': serializer.toJson(payloadJson), + 'baselineUpdatedAt': serializer.toJson(baselineUpdatedAt), + 'createdAt': serializer.toJson(createdAt), + 'tries': serializer.toJson(tries), + 'lastError': serializer.toJson(lastError), + }; + } + + PendingWrite copyWith({ + int? id, + String? domain, + String? verb, + Value targetId = const Value.absent(), + Value tempId = const Value.absent(), + String? payloadJson, + Value baselineUpdatedAt = const Value.absent(), + DateTime? createdAt, + int? tries, + Value lastError = const Value.absent(), + }) => PendingWrite( + id: id ?? this.id, + domain: domain ?? this.domain, + verb: verb ?? this.verb, + targetId: targetId.present ? targetId.value : this.targetId, + tempId: tempId.present ? tempId.value : this.tempId, + payloadJson: payloadJson ?? this.payloadJson, + baselineUpdatedAt: baselineUpdatedAt.present + ? baselineUpdatedAt.value + : this.baselineUpdatedAt, + createdAt: createdAt ?? this.createdAt, + tries: tries ?? this.tries, + lastError: lastError.present ? lastError.value : this.lastError, + ); + PendingWrite copyWithCompanion(PendingWritesCompanion data) { + return PendingWrite( + id: data.id.present ? data.id.value : this.id, + domain: data.domain.present ? data.domain.value : this.domain, + verb: data.verb.present ? data.verb.value : this.verb, + targetId: data.targetId.present ? data.targetId.value : this.targetId, + tempId: data.tempId.present ? data.tempId.value : this.tempId, + payloadJson: data.payloadJson.present + ? data.payloadJson.value + : this.payloadJson, + baselineUpdatedAt: data.baselineUpdatedAt.present + ? data.baselineUpdatedAt.value + : this.baselineUpdatedAt, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + tries: data.tries.present ? data.tries.value : this.tries, + lastError: data.lastError.present ? data.lastError.value : this.lastError, + ); + } + + @override + String toString() { + return (StringBuffer('PendingWrite(') + ..write('id: $id, ') + ..write('domain: $domain, ') + ..write('verb: $verb, ') + ..write('targetId: $targetId, ') + ..write('tempId: $tempId, ') + ..write('payloadJson: $payloadJson, ') + ..write('baselineUpdatedAt: $baselineUpdatedAt, ') + ..write('createdAt: $createdAt, ') + ..write('tries: $tries, ') + ..write('lastError: $lastError') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + domain, + verb, + targetId, + tempId, + payloadJson, + baselineUpdatedAt, + createdAt, + tries, + lastError, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PendingWrite && + other.id == this.id && + other.domain == this.domain && + other.verb == this.verb && + other.targetId == this.targetId && + other.tempId == this.tempId && + other.payloadJson == this.payloadJson && + other.baselineUpdatedAt == this.baselineUpdatedAt && + other.createdAt == this.createdAt && + other.tries == this.tries && + other.lastError == this.lastError); +} + +class PendingWritesCompanion extends UpdateCompanion { + final Value id; + final Value domain; + final Value verb; + final Value targetId; + final Value tempId; + final Value payloadJson; + final Value baselineUpdatedAt; + final Value createdAt; + final Value tries; + final Value lastError; + const PendingWritesCompanion({ + this.id = const Value.absent(), + this.domain = const Value.absent(), + this.verb = const Value.absent(), + this.targetId = const Value.absent(), + this.tempId = const Value.absent(), + this.payloadJson = const Value.absent(), + this.baselineUpdatedAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.tries = const Value.absent(), + this.lastError = const Value.absent(), + }); + PendingWritesCompanion.insert({ + this.id = const Value.absent(), + required String domain, + required String verb, + this.targetId = const Value.absent(), + this.tempId = const Value.absent(), + this.payloadJson = const Value.absent(), + this.baselineUpdatedAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.tries = const Value.absent(), + this.lastError = const Value.absent(), + }) : domain = Value(domain), + verb = Value(verb); + static Insertable custom({ + Expression? id, + Expression? domain, + Expression? verb, + Expression? targetId, + Expression? tempId, + Expression? payloadJson, + Expression? baselineUpdatedAt, + Expression? createdAt, + Expression? tries, + Expression? lastError, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (domain != null) 'domain': domain, + if (verb != null) 'verb': verb, + if (targetId != null) 'target_id': targetId, + if (tempId != null) 'temp_id': tempId, + if (payloadJson != null) 'payload_json': payloadJson, + if (baselineUpdatedAt != null) 'baseline_updated_at': baselineUpdatedAt, + if (createdAt != null) 'created_at': createdAt, + if (tries != null) 'tries': tries, + if (lastError != null) 'last_error': lastError, + }); + } + + PendingWritesCompanion copyWith({ + Value? id, + Value? domain, + Value? verb, + Value? targetId, + Value? tempId, + Value? payloadJson, + Value? baselineUpdatedAt, + Value? createdAt, + Value? tries, + Value? lastError, + }) { + return PendingWritesCompanion( + id: id ?? this.id, + domain: domain ?? this.domain, + verb: verb ?? this.verb, + targetId: targetId ?? this.targetId, + tempId: tempId ?? this.tempId, + payloadJson: payloadJson ?? this.payloadJson, + baselineUpdatedAt: baselineUpdatedAt ?? this.baselineUpdatedAt, + createdAt: createdAt ?? this.createdAt, + tries: tries ?? this.tries, + lastError: lastError ?? this.lastError, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (domain.present) { + map['domain'] = Variable(domain.value); + } + if (verb.present) { + map['verb'] = Variable(verb.value); + } + if (targetId.present) { + map['target_id'] = Variable(targetId.value); + } + if (tempId.present) { + map['temp_id'] = Variable(tempId.value); + } + if (payloadJson.present) { + map['payload_json'] = Variable(payloadJson.value); + } + if (baselineUpdatedAt.present) { + map['baseline_updated_at'] = Variable(baselineUpdatedAt.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (tries.present) { + map['tries'] = Variable(tries.value); + } + if (lastError.present) { + map['last_error'] = Variable(lastError.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PendingWritesCompanion(') + ..write('id: $id, ') + ..write('domain: $domain, ') + ..write('verb: $verb, ') + ..write('targetId: $targetId, ') + ..write('tempId: $tempId, ') + ..write('payloadJson: $payloadJson, ') + ..write('baselineUpdatedAt: $baselineUpdatedAt, ') + ..write('createdAt: $createdAt, ') + ..write('tries: $tries, ') + ..write('lastError: $lastError') + ..write(')')) + .toString(); + } +} + abstract class _$FabledDatabase extends GeneratedDatabase { _$FabledDatabase(QueryExecutor e) : super(e); $FabledDatabaseManager get managers => $FabledDatabaseManager(this); @@ -3845,6 +4448,7 @@ abstract class _$FabledDatabase extends GeneratedDatabase { late final $CachedConversationsTable cachedConversations = $CachedConversationsTable(this); late final $SyncMetadataTable syncMetadata = $SyncMetadataTable(this); + late final $PendingWritesTable pendingWrites = $PendingWritesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -3857,6 +4461,7 @@ abstract class _$FabledDatabase extends GeneratedDatabase { cachedCalendarEvents, cachedConversations, syncMetadata, + pendingWrites, ]; } @@ -5830,6 +6435,301 @@ typedef $$SyncMetadataTableProcessedTableManager = SyncMetadataData, PrefetchHooks Function() >; +typedef $$PendingWritesTableCreateCompanionBuilder = + PendingWritesCompanion Function({ + Value id, + required String domain, + required String verb, + Value targetId, + Value tempId, + Value payloadJson, + Value baselineUpdatedAt, + Value createdAt, + Value tries, + Value lastError, + }); +typedef $$PendingWritesTableUpdateCompanionBuilder = + PendingWritesCompanion Function({ + Value id, + Value domain, + Value verb, + Value targetId, + Value tempId, + Value payloadJson, + Value baselineUpdatedAt, + Value createdAt, + Value tries, + Value lastError, + }); + +class $$PendingWritesTableFilterComposer + extends Composer<_$FabledDatabase, $PendingWritesTable> { + $$PendingWritesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get verb => $composableBuilder( + column: $table.verb, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get targetId => $composableBuilder( + column: $table.targetId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tempId => $composableBuilder( + column: $table.tempId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get payloadJson => $composableBuilder( + column: $table.payloadJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineUpdatedAt => $composableBuilder( + column: $table.baselineUpdatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tries => $composableBuilder( + column: $table.tries, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnFilters(column), + ); +} + +class $$PendingWritesTableOrderingComposer + extends Composer<_$FabledDatabase, $PendingWritesTable> { + $$PendingWritesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get verb => $composableBuilder( + column: $table.verb, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get targetId => $composableBuilder( + column: $table.targetId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tempId => $composableBuilder( + column: $table.tempId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get payloadJson => $composableBuilder( + column: $table.payloadJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get baselineUpdatedAt => $composableBuilder( + column: $table.baselineUpdatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tries => $composableBuilder( + column: $table.tries, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$PendingWritesTableAnnotationComposer + extends Composer<_$FabledDatabase, $PendingWritesTable> { + $$PendingWritesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get domain => + $composableBuilder(column: $table.domain, builder: (column) => column); + + GeneratedColumn get verb => + $composableBuilder(column: $table.verb, builder: (column) => column); + + GeneratedColumn get targetId => + $composableBuilder(column: $table.targetId, builder: (column) => column); + + GeneratedColumn get tempId => + $composableBuilder(column: $table.tempId, builder: (column) => column); + + GeneratedColumn get payloadJson => $composableBuilder( + column: $table.payloadJson, + builder: (column) => column, + ); + + GeneratedColumn get baselineUpdatedAt => $composableBuilder( + column: $table.baselineUpdatedAt, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get tries => + $composableBuilder(column: $table.tries, builder: (column) => column); + + GeneratedColumn get lastError => + $composableBuilder(column: $table.lastError, builder: (column) => column); +} + +class $$PendingWritesTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $PendingWritesTable, + PendingWrite, + $$PendingWritesTableFilterComposer, + $$PendingWritesTableOrderingComposer, + $$PendingWritesTableAnnotationComposer, + $$PendingWritesTableCreateCompanionBuilder, + $$PendingWritesTableUpdateCompanionBuilder, + ( + PendingWrite, + BaseReferences<_$FabledDatabase, $PendingWritesTable, PendingWrite>, + ), + PendingWrite, + PrefetchHooks Function() + > { + $$PendingWritesTableTableManager( + _$FabledDatabase db, + $PendingWritesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PendingWritesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PendingWritesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PendingWritesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value domain = const Value.absent(), + Value verb = const Value.absent(), + Value targetId = const Value.absent(), + Value tempId = const Value.absent(), + Value payloadJson = const Value.absent(), + Value baselineUpdatedAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value tries = const Value.absent(), + Value lastError = const Value.absent(), + }) => PendingWritesCompanion( + id: id, + domain: domain, + verb: verb, + targetId: targetId, + tempId: tempId, + payloadJson: payloadJson, + baselineUpdatedAt: baselineUpdatedAt, + createdAt: createdAt, + tries: tries, + lastError: lastError, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String domain, + required String verb, + Value targetId = const Value.absent(), + Value tempId = const Value.absent(), + Value payloadJson = const Value.absent(), + Value baselineUpdatedAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value tries = const Value.absent(), + Value lastError = const Value.absent(), + }) => PendingWritesCompanion.insert( + id: id, + domain: domain, + verb: verb, + targetId: targetId, + tempId: tempId, + payloadJson: payloadJson, + baselineUpdatedAt: baselineUpdatedAt, + createdAt: createdAt, + tries: tries, + lastError: lastError, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$PendingWritesTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $PendingWritesTable, + PendingWrite, + $$PendingWritesTableFilterComposer, + $$PendingWritesTableOrderingComposer, + $$PendingWritesTableAnnotationComposer, + $$PendingWritesTableCreateCompanionBuilder, + $$PendingWritesTableUpdateCompanionBuilder, + ( + PendingWrite, + BaseReferences<_$FabledDatabase, $PendingWritesTable, PendingWrite>, + ), + PendingWrite, + PrefetchHooks Function() + >; class $FabledDatabaseManager { final _$FabledDatabase _db; @@ -5848,4 +6748,6 @@ class $FabledDatabaseManager { $$CachedConversationsTableTableManager(_db, _db.cachedConversations); $$SyncMetadataTableTableManager get syncMetadata => $$SyncMetadataTableTableManager(_db, _db.syncMetadata); + $$PendingWritesTableTableManager get pendingWrites => + $$PendingWritesTableTableManager(_db, _db.pendingWrites); } diff --git a/lib/data/repositories/events_repository.dart b/lib/data/repositories/events_repository.dart index 41dd3a4..373ff19 100644 --- a/lib/data/repositories/events_repository.dart +++ b/lib/data/repositories/events_repository.dart @@ -3,10 +3,13 @@ import '../api/events_api.dart'; import '../local/database.dart'; import '../models/calendar_event.dart'; -/// Calendar events repository with read-through caching for Tier 2 offline -/// support. Range-scoped: each `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. +/// 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; @@ -26,20 +29,116 @@ class EventsRepository { } Future createEvent(Map payload) async { - final event = await _api.createEvent(payload); - await _db.upsertEvent(event); - return event; + 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 updateEvent( int id, Map fields) async { - final updated = await _api.updateEvent(id, fields); - await _db.upsertEvent(updated); - return updated; + 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 deleteEvent(int id) async { - await _api.deleteEvent(id); - await _db.deleteEvent(id); + 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 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 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; +} diff --git a/lib/data/repositories/milestones_repository.dart b/lib/data/repositories/milestones_repository.dart index c9eb364..2d3b4cd 100644 --- a/lib/data/repositories/milestones_repository.dart +++ b/lib/data/repositories/milestones_repository.dart @@ -3,8 +3,9 @@ import '../api/milestones_api.dart'; import '../local/database.dart'; import '../models/milestone.dart'; -/// Milestones repository with read-through caching for Tier 2 offline support. -/// Mirrors the pattern in NotesRepository — see that file for full notes. +/// 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; final FabledDatabase _db; @@ -40,14 +41,45 @@ class MilestonesRepository { String? description, int orderIndex = 0, }) async { - final milestone = await _api.create( - projectId, - title: title, - description: description, - orderIndex: orderIndex, - ); - await _db.upsertMilestone(milestone); - return milestone; + 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 update( @@ -55,13 +87,78 @@ class MilestonesRepository { int milestoneId, Map fields, ) async { - final updated = await _api.update(projectId, milestoneId, fields); - await _db.upsertMilestone(updated); - return updated; + 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 = { + 'project_id': projectId, + ...fields, + }; + await _db.updatePendingPayload(queued.id, merged); + return optimistic; + } + } + await _db.enqueuePending( + domain: kSyncDomainMilestones, + verb: kWriteVerbUpdate, + targetId: milestoneId, + payload: {'project_id': projectId, ...fields}, + baselineUpdatedAt: cached.updatedAt, + ); + return optimistic; + } } Future delete(int projectId, int milestoneId) async { - await _api.delete(projectId, milestoneId); - await _db.deleteMilestone(milestoneId); + 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 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, + ); +} diff --git a/lib/data/repositories/notes_repository.dart b/lib/data/repositories/notes_repository.dart index 41042ac..f9ec037 100644 --- a/lib/data/repositories/notes_repository.dart +++ b/lib/data/repositories/notes_repository.dart @@ -3,19 +3,22 @@ import '../api/notes_api.dart'; import '../local/database.dart'; import '../models/note.dart'; -/// Notes repository with read-through caching for Tier 2 offline support. +/// 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 (create / update / delete) hit the -/// server then sync the cache; offline write queueing is Phase 3 work and -/// not yet wired here — write methods will throw on `NetworkException` in -/// the meantime. +/// 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 — its -/// only job is to serve what's cached when the network is unreachable. +/// heartbeat). This repository deliberately does not poke that state. class NotesRepository { final NotesApi _api; final FabledDatabase _db; @@ -53,15 +56,44 @@ class NotesRepository { int? projectId, String noteType = 'note', }) async { - final note = await _api.create( - title, - body, - tags: tags, - projectId: projectId, - noteType: noteType, - ); - await _db.upsertNote(note); - return note; + 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 update( @@ -73,21 +105,75 @@ class NotesRepository { bool clearProject = false, String noteType = 'note', }) async { - final updated = await _api.update( - id, - title, - body, - tags: tags, - projectId: projectId, - clearProject: clearProject, - noteType: noteType, - ); - await _db.upsertNote(updated); - return updated; + 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 = { + '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 delete(int id) async { - await _api.delete(id); - await _db.deleteNote(id); + 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, + ); + } } } diff --git a/lib/data/repositories/projects_repository.dart b/lib/data/repositories/projects_repository.dart index e6b617d..ccd4cf1 100644 --- a/lib/data/repositories/projects_repository.dart +++ b/lib/data/repositories/projects_repository.dart @@ -3,8 +3,8 @@ import '../api/projects_api.dart'; import '../local/database.dart'; import '../models/project.dart'; -/// Projects repository with read-through caching for Tier 2 offline support. -/// Mirrors the pattern in NotesRepository — see that file for full notes. +/// 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 @@ -63,25 +63,106 @@ class ProjectsRepository { String? color, String status = 'active', }) async { - final project = await _api.create( - title: title, - description: description, - goal: goal, - color: color, - status: status, - ); - await _db.upsertProject(project); - return project; + 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 update(int id, Map fields) async { - final updated = await _api.update(id, fields); - await _db.upsertProject(updated); - return updated; + 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 delete(int id) async { - await _api.delete(id); - await _db.deleteProject(id); + 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 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, + ); +} diff --git a/lib/data/repositories/tasks_repository.dart b/lib/data/repositories/tasks_repository.dart index 3bb7771..f4d04b4 100644 --- a/lib/data/repositories/tasks_repository.dart +++ b/lib/data/repositories/tasks_repository.dart @@ -3,8 +3,8 @@ import '../api/tasks_api.dart'; import '../local/database.dart'; import '../models/task.dart'; -/// Tasks repository with read-through caching for Tier 2 offline support. -/// Mirrors the pattern in NotesRepository — see that file for full notes. +/// 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; final FabledDatabase _db; @@ -68,27 +68,129 @@ class TasksRepository { int? projectId, int? parentId, }) async { - final task = await _api.create( - title: title, - description: description, - status: status, - priority: priority, - dueDate: dueDate, - projectId: projectId, - parentId: parentId, - ); - await _db.upsertTask(task); - return task; + try { + final task = await _api.create( + title: title, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + 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 update(int id, Map fields) async { - final updated = await _api.update(id, fields); - await _db.upsertTask(updated); - return updated; + 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 delete(int id) async { - await _api.delete(id); - await _db.deleteTask(id); + 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 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, + ); +} diff --git a/lib/data/repositories/write_queue.dart b/lib/data/repositories/write_queue.dart new file mode 100644 index 0000000..462b524 --- /dev/null +++ b/lib/data/repositories/write_queue.dart @@ -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 _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); +} diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 1e5ada6..973e3f4 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -25,6 +25,7 @@ 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((ref) { @@ -148,3 +149,27 @@ final eventsRepositoryProvider = Provider((ref) { 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((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((ref) { + return ref.watch(fabledDatabaseProvider).watchQueueDepth(); +}); + +final writeQueueFailuresProvider = StreamProvider((ref) { + return ref.watch(writeQueueProvider).failures; +}); diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart index a53e97b..35b2d7e 100644 --- a/lib/widgets/offline_banner.dart +++ b/lib/widgets/offline_banner.dart @@ -84,9 +84,17 @@ class _OfflineBannerState extends ConsumerState { final scheme = Theme.of(context).colorScheme; final ago = _lastSync == null ? null : _relativeAgo(_lastSync!); - final message = ago == null + // 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, @@ -117,7 +125,7 @@ class _OfflineBannerState extends ConsumerState { height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Retry'), + : Text(retryLabel), ), ], ), diff --git a/test/widget_test.dart b/test/widget_test.dart index 211d3b4..9dd90d8 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,5 +1,6 @@ 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'; @@ -207,4 +208,36 @@ void main() { }); }); + 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('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')); + }); + }); + }