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>
This commit is contained in:
2026-04-28 22:08:40 -04:00
parent fe10067761
commit 7df7b5ff85
14 changed files with 2090 additions and 93 deletions
+29
View File
@@ -7,6 +7,7 @@ import 'package:flutter_timezone/flutter_timezone.dart';
import 'core/constants.dart'; import 'core/constants.dart';
import 'core/theme.dart'; import 'core/theme.dart';
import 'data/repositories/write_queue.dart';
import 'providers/api_client_provider.dart'; import 'providers/api_client_provider.dart';
import 'providers/auth_provider.dart'; import 'providers/auth_provider.dart';
import 'core/exceptions.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Show update dialog once when a new version is detected. // Show update dialog once when a new version is detected.
@@ -341,6 +353,23 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
.addPostFrameCallback((_) => _showUpdateSnackbar(next)); .addPostFrameCallback((_) => _showUpdateSnackbar(next));
} }
}); });
// Phase 3 — drain the offline write queue when we transition into
// an online state. Fires for unknown→authenticated (cold start),
// offline→authenticated (came back), and unauthenticated→authenticated
// (login). Idempotent if the queue is empty.
ref.listen(authProvider, (prev, next) {
if (next == AuthStatus.authenticated &&
prev != AuthStatus.authenticated) {
ref.read(writeQueueProvider).replay();
}
});
// Phase 3 — surface queue failures (overwrites, missing targets, 4xx
// rejections) so the user knows their offline edit didn't land.
ref.listen(writeQueueFailuresProvider, (_, next) {
next.whenData(_showQueueFailureSnackbar);
});
final tabs = _tabs(); final tabs = _tabs();
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
final index = _tabIndex(location, tabs); final index = _tabIndex(location, tabs);
+9
View File
@@ -17,3 +17,12 @@ class AuthException extends AppException {
class NotFoundException extends AppException { class NotFoundException extends AppException {
const NotFoundException(super.message); 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);
}
+16
View File
@@ -63,5 +63,21 @@ AppException dioToApp(DioException e) {
final status = e.response?.statusCode; final status = e.response?.statusCode;
if (status == 401) return const AuthException('Not authenticated.'); if (status == 401) return const AuthException('Not authenticated.');
if (status == 404) return const NotFoundException('Resource not found.'); 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.'); return NetworkException(e.message ?? 'Unknown network error.');
} }
String? _extractServerErrorMessage(Response? resp) {
final data = resp?.data;
if (data is Map<String, dynamic>) {
for (final key in const ['detail', 'error', 'message']) {
final value = data[key];
if (value is String && value.isNotEmpty) return value;
}
}
return null;
}
+126 -1
View File
@@ -136,6 +136,29 @@ class SyncMetadata extends Table {
Set<Column> get primaryKey => {domain}; Set<Column> get primaryKey => {domain};
} }
/// Phase 3 — generic offline-write queue. One row per pending API call;
/// drained in oldest-first order when the device comes back online.
///
/// `targetId` is the server id for updates/deletes; `tempId` is the
/// client-allocated negative placeholder for offline creates. `payloadJson`
/// is the typed args the API method needs (verb-specific shape).
/// `baselineUpdatedAt` snapshots the cached row's `updated_at` at edit
/// time so the replayer can drop the op if the server has moved on
/// (server-wins conflict resolution).
class PendingWrites extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get domain => text()();
TextColumn get verb => text()();
IntColumn get targetId => integer().nullable()();
IntColumn get tempId => integer().nullable()();
TextColumn get payloadJson => text().withDefault(const Constant('{}'))();
DateTimeColumn get baselineUpdatedAt => dateTime().nullable()();
DateTimeColumn get createdAt =>
dateTime().clientDefault(() => DateTime.now())();
IntColumn get tries => integer().withDefault(const Constant(0))();
TextColumn get lastError => text().nullable()();
}
const String kSyncDomainNotes = 'notes'; const String kSyncDomainNotes = 'notes';
const String kSyncDomainTasks = 'tasks'; const String kSyncDomainTasks = 'tasks';
const String kSyncDomainProjects = 'projects'; const String kSyncDomainProjects = 'projects';
@@ -143,6 +166,10 @@ const String kSyncDomainMilestones = 'milestones';
const String kSyncDomainEvents = 'events'; const String kSyncDomainEvents = 'events';
const String kSyncDomainConversations = 'conversations'; const String kSyncDomainConversations = 'conversations';
const String kWriteVerbCreate = 'create';
const String kWriteVerbUpdate = 'update';
const String kWriteVerbDelete = 'delete';
@DriftDatabase(tables: [ @DriftDatabase(tables: [
CachedNotes, CachedNotes,
CachedTasks, CachedTasks,
@@ -151,13 +178,14 @@ const String kSyncDomainConversations = 'conversations';
CachedCalendarEvents, CachedCalendarEvents,
CachedConversations, CachedConversations,
SyncMetadata, SyncMetadata,
PendingWrites,
]) ])
class FabledDatabase extends _$FabledDatabase { class FabledDatabase extends _$FabledDatabase {
FabledDatabase() : super(_openConnection()); FabledDatabase() : super(_openConnection());
FabledDatabase.forTesting(super.executor); FabledDatabase.forTesting(super.executor);
@override @override
int get schemaVersion => 2; int get schemaVersion => 3;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -171,6 +199,10 @@ class FabledDatabase extends _$FabledDatabase {
await m.createTable(cachedCalendarEvents); await m.createTable(cachedCalendarEvents);
await m.createTable(cachedConversations); 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<int> nextTempId() async {
final query = customSelect(
'SELECT MIN(temp_id) AS m FROM pending_writes WHERE temp_id IS NOT NULL',
);
final row = await query.getSingleOrNull();
final current = row?.read<int?>('m');
return (current ?? 0) - 1;
}
Future<int> enqueuePending({
required String domain,
required String verb,
int? targetId,
int? tempId,
Map<String, dynamic> payload = const {},
DateTime? baselineUpdatedAt,
}) {
return into(pendingWrites).insert(
PendingWritesCompanion.insert(
domain: domain,
verb: verb,
targetId: Value(targetId),
tempId: Value(tempId),
payloadJson: Value(jsonEncode(payload)),
baselineUpdatedAt: Value(baselineUpdatedAt),
),
);
}
/// Find the queued create-op for an offline-created row so a follow-up
/// edit can be coalesced into the original payload (no separate update
/// op gets queued). Returns null if no matching create is queued.
Future<PendingWrite?> findQueuedCreate(String domain, int tempId) {
return (select(pendingWrites)
..where((t) =>
t.domain.equals(domain) &
t.verb.equals(kWriteVerbCreate) &
t.tempId.equals(tempId)))
.getSingleOrNull();
}
Future<void> updatePendingPayload(int opId, Map<String, dynamic> payload) {
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))),
);
}
Future<void> deletePending(int opId) {
return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go();
}
/// Pending ops oldest first — replay order.
Future<List<PendingWrite>> listPending() {
return (select(pendingWrites)
..orderBy([(t) => OrderingTerm.asc(t.id)]))
.get();
}
Future<int> queueDepth() async {
final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes')
.getSingle();
return row.read<int>('c');
}
Future<void> markPendingFailed(int opId, String error) {
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
PendingWritesCompanion(
tries: const Value.absent(),
lastError: Value(error),
),
);
}
Future<void> incrementPendingTries(int opId) {
return customStatement(
'UPDATE pending_writes SET tries = tries + 1 WHERE id = ?',
[opId],
);
}
/// Watch queue depth — used by OfflineBanner for the "Retry (N)" affordance.
Stream<int> watchQueueDepth() {
final query = customSelect(
'SELECT COUNT(*) AS c FROM pending_writes',
readsFrom: {pendingWrites},
);
return query.watchSingle().map((row) => row.read<int>('c'));
}
} }
// ── Row ↔ model converters ────────────────────────────────────────────────── // ── Row ↔ model converters ──────────────────────────────────────────────────
+902
View File
@@ -3831,6 +3831,609 @@ class SyncMetadataCompanion extends UpdateCompanion<SyncMetadataData> {
} }
} }
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<int> id = GeneratedColumn<int>(
'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<String> domain = GeneratedColumn<String>(
'domain',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _verbMeta = const VerificationMeta('verb');
@override
late final GeneratedColumn<String> verb = GeneratedColumn<String>(
'verb',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _targetIdMeta = const VerificationMeta(
'targetId',
);
@override
late final GeneratedColumn<int> targetId = GeneratedColumn<int>(
'target_id',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _tempIdMeta = const VerificationMeta('tempId');
@override
late final GeneratedColumn<int> tempId = GeneratedColumn<int>(
'temp_id',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _payloadJsonMeta = const VerificationMeta(
'payloadJson',
);
@override
late final GeneratedColumn<String> payloadJson = GeneratedColumn<String>(
'payload_json',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('{}'),
);
static const VerificationMeta _baselineUpdatedAtMeta = const VerificationMeta(
'baselineUpdatedAt',
);
@override
late final GeneratedColumn<DateTime> baselineUpdatedAt =
GeneratedColumn<DateTime>(
'baseline_updated_at',
aliasedName,
true,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
clientDefault: () => DateTime.now(),
);
static const VerificationMeta _triesMeta = const VerificationMeta('tries');
@override
late final GeneratedColumn<int> tries = GeneratedColumn<int>(
'tries',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
static const VerificationMeta _lastErrorMeta = const VerificationMeta(
'lastError',
);
@override
late final GeneratedColumn<String> lastError = GeneratedColumn<String>(
'last_error',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> 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<PendingWrite> 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<GeneratedColumn> get $primaryKey => {id};
@override
PendingWrite map(Map<String, dynamic> 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<PendingWrite> {
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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['domain'] = Variable<String>(domain);
map['verb'] = Variable<String>(verb);
if (!nullToAbsent || targetId != null) {
map['target_id'] = Variable<int>(targetId);
}
if (!nullToAbsent || tempId != null) {
map['temp_id'] = Variable<int>(tempId);
}
map['payload_json'] = Variable<String>(payloadJson);
if (!nullToAbsent || baselineUpdatedAt != null) {
map['baseline_updated_at'] = Variable<DateTime>(baselineUpdatedAt);
}
map['created_at'] = Variable<DateTime>(createdAt);
map['tries'] = Variable<int>(tries);
if (!nullToAbsent || lastError != null) {
map['last_error'] = Variable<String>(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<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return PendingWrite(
id: serializer.fromJson<int>(json['id']),
domain: serializer.fromJson<String>(json['domain']),
verb: serializer.fromJson<String>(json['verb']),
targetId: serializer.fromJson<int?>(json['targetId']),
tempId: serializer.fromJson<int?>(json['tempId']),
payloadJson: serializer.fromJson<String>(json['payloadJson']),
baselineUpdatedAt: serializer.fromJson<DateTime?>(
json['baselineUpdatedAt'],
),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
tries: serializer.fromJson<int>(json['tries']),
lastError: serializer.fromJson<String?>(json['lastError']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'domain': serializer.toJson<String>(domain),
'verb': serializer.toJson<String>(verb),
'targetId': serializer.toJson<int?>(targetId),
'tempId': serializer.toJson<int?>(tempId),
'payloadJson': serializer.toJson<String>(payloadJson),
'baselineUpdatedAt': serializer.toJson<DateTime?>(baselineUpdatedAt),
'createdAt': serializer.toJson<DateTime>(createdAt),
'tries': serializer.toJson<int>(tries),
'lastError': serializer.toJson<String?>(lastError),
};
}
PendingWrite copyWith({
int? id,
String? domain,
String? verb,
Value<int?> targetId = const Value.absent(),
Value<int?> tempId = const Value.absent(),
String? payloadJson,
Value<DateTime?> baselineUpdatedAt = const Value.absent(),
DateTime? createdAt,
int? tries,
Value<String?> 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<PendingWrite> {
final Value<int> id;
final Value<String> domain;
final Value<String> verb;
final Value<int?> targetId;
final Value<int?> tempId;
final Value<String> payloadJson;
final Value<DateTime?> baselineUpdatedAt;
final Value<DateTime> createdAt;
final Value<int> tries;
final Value<String?> 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<PendingWrite> custom({
Expression<int>? id,
Expression<String>? domain,
Expression<String>? verb,
Expression<int>? targetId,
Expression<int>? tempId,
Expression<String>? payloadJson,
Expression<DateTime>? baselineUpdatedAt,
Expression<DateTime>? createdAt,
Expression<int>? tries,
Expression<String>? 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<int>? id,
Value<String>? domain,
Value<String>? verb,
Value<int?>? targetId,
Value<int?>? tempId,
Value<String>? payloadJson,
Value<DateTime?>? baselineUpdatedAt,
Value<DateTime>? createdAt,
Value<int>? tries,
Value<String?>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (domain.present) {
map['domain'] = Variable<String>(domain.value);
}
if (verb.present) {
map['verb'] = Variable<String>(verb.value);
}
if (targetId.present) {
map['target_id'] = Variable<int>(targetId.value);
}
if (tempId.present) {
map['temp_id'] = Variable<int>(tempId.value);
}
if (payloadJson.present) {
map['payload_json'] = Variable<String>(payloadJson.value);
}
if (baselineUpdatedAt.present) {
map['baseline_updated_at'] = Variable<DateTime>(baselineUpdatedAt.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (tries.present) {
map['tries'] = Variable<int>(tries.value);
}
if (lastError.present) {
map['last_error'] = Variable<String>(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 { abstract class _$FabledDatabase extends GeneratedDatabase {
_$FabledDatabase(QueryExecutor e) : super(e); _$FabledDatabase(QueryExecutor e) : super(e);
$FabledDatabaseManager get managers => $FabledDatabaseManager(this); $FabledDatabaseManager get managers => $FabledDatabaseManager(this);
@@ -3845,6 +4448,7 @@ abstract class _$FabledDatabase extends GeneratedDatabase {
late final $CachedConversationsTable cachedConversations = late final $CachedConversationsTable cachedConversations =
$CachedConversationsTable(this); $CachedConversationsTable(this);
late final $SyncMetadataTable syncMetadata = $SyncMetadataTable(this); late final $SyncMetadataTable syncMetadata = $SyncMetadataTable(this);
late final $PendingWritesTable pendingWrites = $PendingWritesTable(this);
@override @override
Iterable<TableInfo<Table, Object?>> get allTables => Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>(); allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@@ -3857,6 +4461,7 @@ abstract class _$FabledDatabase extends GeneratedDatabase {
cachedCalendarEvents, cachedCalendarEvents,
cachedConversations, cachedConversations,
syncMetadata, syncMetadata,
pendingWrites,
]; ];
} }
@@ -5830,6 +6435,301 @@ typedef $$SyncMetadataTableProcessedTableManager =
SyncMetadataData, SyncMetadataData,
PrefetchHooks Function() PrefetchHooks Function()
>; >;
typedef $$PendingWritesTableCreateCompanionBuilder =
PendingWritesCompanion Function({
Value<int> id,
required String domain,
required String verb,
Value<int?> targetId,
Value<int?> tempId,
Value<String> payloadJson,
Value<DateTime?> baselineUpdatedAt,
Value<DateTime> createdAt,
Value<int> tries,
Value<String?> lastError,
});
typedef $$PendingWritesTableUpdateCompanionBuilder =
PendingWritesCompanion Function({
Value<int> id,
Value<String> domain,
Value<String> verb,
Value<int?> targetId,
Value<int?> tempId,
Value<String> payloadJson,
Value<DateTime?> baselineUpdatedAt,
Value<DateTime> createdAt,
Value<int> tries,
Value<String?> lastError,
});
class $$PendingWritesTableFilterComposer
extends Composer<_$FabledDatabase, $PendingWritesTable> {
$$PendingWritesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get domain => $composableBuilder(
column: $table.domain,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get verb => $composableBuilder(
column: $table.verb,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get targetId => $composableBuilder(
column: $table.targetId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get tempId => $composableBuilder(
column: $table.tempId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get payloadJson => $composableBuilder(
column: $table.payloadJson,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get baselineUpdatedAt => $composableBuilder(
column: $table.baselineUpdatedAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get tries => $composableBuilder(
column: $table.tries,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> 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<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get domain => $composableBuilder(
column: $table.domain,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get verb => $composableBuilder(
column: $table.verb,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get targetId => $composableBuilder(
column: $table.targetId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get tempId => $composableBuilder(
column: $table.tempId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get payloadJson => $composableBuilder(
column: $table.payloadJson,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get baselineUpdatedAt => $composableBuilder(
column: $table.baselineUpdatedAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get tries => $composableBuilder(
column: $table.tries,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> 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<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get domain =>
$composableBuilder(column: $table.domain, builder: (column) => column);
GeneratedColumn<String> get verb =>
$composableBuilder(column: $table.verb, builder: (column) => column);
GeneratedColumn<int> get targetId =>
$composableBuilder(column: $table.targetId, builder: (column) => column);
GeneratedColumn<int> get tempId =>
$composableBuilder(column: $table.tempId, builder: (column) => column);
GeneratedColumn<String> get payloadJson => $composableBuilder(
column: $table.payloadJson,
builder: (column) => column,
);
GeneratedColumn<DateTime> get baselineUpdatedAt => $composableBuilder(
column: $table.baselineUpdatedAt,
builder: (column) => column,
);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<int> get tries =>
$composableBuilder(column: $table.tries, builder: (column) => column);
GeneratedColumn<String> 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<int> id = const Value.absent(),
Value<String> domain = const Value.absent(),
Value<String> verb = const Value.absent(),
Value<int?> targetId = const Value.absent(),
Value<int?> tempId = const Value.absent(),
Value<String> payloadJson = const Value.absent(),
Value<DateTime?> baselineUpdatedAt = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int> tries = const Value.absent(),
Value<String?> 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<int> id = const Value.absent(),
required String domain,
required String verb,
Value<int?> targetId = const Value.absent(),
Value<int?> tempId = const Value.absent(),
Value<String> payloadJson = const Value.absent(),
Value<DateTime?> baselineUpdatedAt = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<int> tries = const Value.absent(),
Value<String?> 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 { class $FabledDatabaseManager {
final _$FabledDatabase _db; final _$FabledDatabase _db;
@@ -5848,4 +6748,6 @@ class $FabledDatabaseManager {
$$CachedConversationsTableTableManager(_db, _db.cachedConversations); $$CachedConversationsTableTableManager(_db, _db.cachedConversations);
$$SyncMetadataTableTableManager get syncMetadata => $$SyncMetadataTableTableManager get syncMetadata =>
$$SyncMetadataTableTableManager(_db, _db.syncMetadata); $$SyncMetadataTableTableManager(_db, _db.syncMetadata);
$$PendingWritesTableTableManager get pendingWrites =>
$$PendingWritesTableTableManager(_db, _db.pendingWrites);
} }
+111 -12
View File
@@ -3,10 +3,13 @@ import '../api/events_api.dart';
import '../local/database.dart'; import '../local/database.dart';
import '../models/calendar_event.dart'; import '../models/calendar_event.dart';
/// Calendar events repository with read-through caching for Tier 2 offline /// Calendar events repository with read-through caching (Phase 1+2) and
/// support. Range-scoped: each `getEvents(from, to)` mirrors that window into /// offline-write queueing (Phase 3). Range-scoped reads: `getEvents(from, to)`
/// the cache (events outside the window are left alone) so successive /// mirrors that window into the cache (events outside the window are left
/// disjoint range fetches don't clobber each other. /// 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 { class EventsRepository {
final EventsApi _api; final EventsApi _api;
final FabledDatabase _db; final FabledDatabase _db;
@@ -26,20 +29,116 @@ class EventsRepository {
} }
Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async { Future<CalendarEvent> createEvent(Map<String, dynamic> payload) async {
final event = await _api.createEvent(payload); try {
await _db.upsertEvent(event); final event = await _api.createEvent(payload);
return event; await _db.upsertEvent(event);
return event;
} on NetworkException {
final tempId = await _db.nextTempId();
final optimistic = _eventFromPayload(tempId, payload);
await _db.upsertEvent(optimistic);
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbCreate,
tempId: tempId,
payload: payload,
);
return optimistic;
}
} }
Future<CalendarEvent> updateEvent( Future<CalendarEvent> updateEvent(
int id, Map<String, dynamic> fields) async { int id, Map<String, dynamic> fields) async {
final updated = await _api.updateEvent(id, fields); try {
await _db.upsertEvent(updated); final updated = await _api.updateEvent(id, fields);
return updated; await _db.upsertEvent(updated);
return updated;
} on NetworkException {
final cached = (await _db.getEventsInRange(
DateTime.fromMicrosecondsSinceEpoch(0),
DateTime.now().add(const Duration(days: 365 * 100)),
))
.where((e) => e.id == id)
.firstOrNull;
if (cached == null) rethrow;
final optimistic = _applyEventFields(cached, fields);
await _db.upsertEvent(optimistic);
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, fields);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbUpdate,
targetId: id,
payload: fields,
);
return optimistic;
}
} }
Future<void> deleteEvent(int id) async { Future<void> deleteEvent(int id) async {
await _api.deleteEvent(id); try {
await _db.deleteEvent(id); await _api.deleteEvent(id);
await _db.deleteEvent(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainEvents, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteEvent(id);
return;
}
await _db.deleteEvent(id);
await _db.enqueuePending(
domain: kSyncDomainEvents,
verb: kWriteVerbDelete,
targetId: id,
);
}
} }
} }
CalendarEvent _eventFromPayload(int id, Map<String, dynamic> p) {
return CalendarEvent(
id: id,
title: p['title'] as String? ?? '',
startDt: _parseIso(p['start_dt'])!,
endDt: _parseIso(p['end_dt']),
allDay: p['all_day'] as bool? ?? false,
description: p['description'] as String? ?? '',
location: p['location'] as String? ?? '',
color: p['color'] as String? ?? '',
recurrence: p['recurrence'] as String?,
projectId: p['project_id'] as int?,
reminderMinutes: p['reminder_minutes'] as int?,
);
}
CalendarEvent _applyEventFields(CalendarEvent e, Map<String, dynamic> f) {
return CalendarEvent(
id: e.id,
title: f['title'] as String? ?? e.title,
startDt: _parseIso(f['start_dt']) ?? e.startDt,
endDt: f.containsKey('end_dt') ? _parseIso(f['end_dt']) : e.endDt,
allDay: f['all_day'] as bool? ?? e.allDay,
description: f['description'] as String? ?? e.description,
location: f['location'] as String? ?? e.location,
color: f['color'] as String? ?? e.color,
recurrence:
f.containsKey('recurrence') ? f['recurrence'] as String? : e.recurrence,
projectId: f.containsKey('project_id')
? f['project_id'] as int?
: e.projectId,
reminderMinutes: f.containsKey('reminder_minutes')
? f['reminder_minutes'] as int?
: e.reminderMinutes,
);
}
DateTime? _parseIso(dynamic raw) {
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw)?.toLocal();
return null;
}
+112 -15
View File
@@ -3,8 +3,9 @@ import '../api/milestones_api.dart';
import '../local/database.dart'; import '../local/database.dart';
import '../models/milestone.dart'; import '../models/milestone.dart';
/// Milestones repository with read-through caching for Tier 2 offline support. /// Milestones repository with read-through caching (Phase 1+2) and
/// Mirrors the pattern in NotesRepository — see that file for full notes. /// offline-write queueing (Phase 3). See `notes_repository.dart` for the
/// full pattern.
class MilestonesRepository { class MilestonesRepository {
final MilestonesApi _api; final MilestonesApi _api;
final FabledDatabase _db; final FabledDatabase _db;
@@ -40,14 +41,45 @@ class MilestonesRepository {
String? description, String? description,
int orderIndex = 0, int orderIndex = 0,
}) async { }) async {
final milestone = await _api.create( try {
projectId, final milestone = await _api.create(
title: title, projectId,
description: description, title: title,
orderIndex: orderIndex, description: description,
); orderIndex: orderIndex,
await _db.upsertMilestone(milestone); );
return milestone; await _db.upsertMilestone(milestone);
return milestone;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Milestone(
id: tempId,
projectId: projectId,
title: title,
description: description,
status: 'active',
orderIndex: orderIndex,
total: 0,
completed: 0,
pct: 0.0,
createdAt: now,
updatedAt: now,
);
await _db.upsertMilestone(optimistic);
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'project_id': projectId,
'title': title,
'description': description,
'order_index': orderIndex,
},
);
return optimistic;
}
} }
Future<Milestone> update( Future<Milestone> update(
@@ -55,13 +87,78 @@ class MilestonesRepository {
int milestoneId, int milestoneId,
Map<String, dynamic> fields, Map<String, dynamic> fields,
) async { ) async {
final updated = await _api.update(projectId, milestoneId, fields); try {
await _db.upsertMilestone(updated); final updated = await _api.update(projectId, milestoneId, fields);
return updated; await _db.upsertMilestone(updated);
return updated;
} on NetworkException {
final cached = (await _db.getMilestonesForProject(projectId))
.where((m) => m.id == milestoneId)
.firstOrNull;
if (cached == null) rethrow;
final optimistic = _applyMilestoneFields(cached, fields);
await _db.upsertMilestone(optimistic);
if (milestoneId < 0) {
final queued =
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
if (queued != null) {
// Carry project_id through coalesced payload — replay needs it.
final merged = <String, dynamic>{
'project_id': projectId,
...fields,
};
await _db.updatePendingPayload(queued.id, merged);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbUpdate,
targetId: milestoneId,
payload: <String, dynamic>{'project_id': projectId, ...fields},
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
} }
Future<void> delete(int projectId, int milestoneId) async { Future<void> delete(int projectId, int milestoneId) async {
await _api.delete(projectId, milestoneId); try {
await _db.deleteMilestone(milestoneId); await _api.delete(projectId, milestoneId);
await _db.deleteMilestone(milestoneId);
} on NetworkException {
if (milestoneId < 0) {
final queued =
await _db.findQueuedCreate(kSyncDomainMilestones, milestoneId);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteMilestone(milestoneId);
return;
}
await _db.deleteMilestone(milestoneId);
await _db.enqueuePending(
domain: kSyncDomainMilestones,
verb: kWriteVerbDelete,
targetId: milestoneId,
payload: {'project_id': projectId},
);
}
} }
} }
Milestone _applyMilestoneFields(Milestone m, Map<String, dynamic> f) {
return Milestone(
id: m.id,
projectId: m.projectId,
title: f['title'] as String? ?? m.title,
description: f.containsKey('description')
? f['description'] as String?
: m.description,
status: f['status'] as String? ?? m.status,
orderIndex: f['order_index'] as int? ?? m.orderIndex,
total: m.total,
completed: m.completed,
pct: m.pct,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
);
}
+115 -29
View File
@@ -3,19 +3,22 @@ import '../api/notes_api.dart';
import '../local/database.dart'; import '../local/database.dart';
import '../models/note.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 /// Reads attempt the network first; on success the response is written to
/// the local Drift cache. On `NetworkException` the read falls back to the /// 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 /// cache (rethrowing if the cache is empty so the UI can show its
/// fresh-install empty state). Writes (create / update / delete) hit the /// fresh-install empty state).
/// server then sync the cache; offline write queueing is Phase 3 work and ///
/// not yet wired here — write methods will throw on `NetworkException` in /// Writes hit the server then sync the cache. On `NetworkException` the
/// the meantime. /// 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 /// `AuthStatus.offline` is owned by `AuthNotifier.verify()` (a periodic
/// heartbeat). This repository deliberately does not poke that state — its /// heartbeat). This repository deliberately does not poke that state.
/// only job is to serve what's cached when the network is unreachable.
class NotesRepository { class NotesRepository {
final NotesApi _api; final NotesApi _api;
final FabledDatabase _db; final FabledDatabase _db;
@@ -53,15 +56,44 @@ class NotesRepository {
int? projectId, int? projectId,
String noteType = 'note', String noteType = 'note',
}) async { }) async {
final note = await _api.create( try {
title, final note = await _api.create(
body, title,
tags: tags, body,
projectId: projectId, tags: tags,
noteType: noteType, projectId: projectId,
); noteType: noteType,
await _db.upsertNote(note); );
return note; await _db.upsertNote(note);
return note;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Note(
id: tempId,
title: title,
body: body,
tags: tags,
noteType: noteType,
projectId: projectId,
createdAt: now,
updatedAt: now,
);
await _db.upsertNote(optimistic);
await _db.enqueuePending(
domain: kSyncDomainNotes,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'title': title,
'body': body,
'tags': tags,
'project_id': projectId,
'note_type': noteType,
},
);
return optimistic;
}
} }
Future<Note> update( Future<Note> update(
@@ -73,21 +105,75 @@ class NotesRepository {
bool clearProject = false, bool clearProject = false,
String noteType = 'note', String noteType = 'note',
}) async { }) async {
final updated = await _api.update( try {
id, final updated = await _api.update(
title, id,
body, title,
tags: tags, body,
projectId: projectId, tags: tags,
clearProject: clearProject, projectId: projectId,
noteType: noteType, clearProject: clearProject,
); noteType: noteType,
await _db.upsertNote(updated); );
return updated; await _db.upsertNote(updated);
return updated;
} on NetworkException {
final cached = await _db.getNote(id);
if (cached == null) rethrow;
final payload = <String, dynamic>{
'title': title,
'body': body,
'tags': tags,
'project_id': projectId,
'clear_project': clearProject,
'note_type': noteType,
};
final optimistic = cached.copyWith(
title: title,
body: body,
tags: tags,
noteType: noteType,
projectId: clearProject ? null : projectId,
);
await _db.upsertNote(optimistic);
// Edits to an offline-created row coalesce into the queued create.
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainNotes, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, payload);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainNotes,
verb: kWriteVerbUpdate,
targetId: id,
payload: payload,
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
} }
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete(id); try {
await _db.deleteNote(id); 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,
);
}
} }
} }
+97 -16
View File
@@ -3,8 +3,8 @@ import '../api/projects_api.dart';
import '../local/database.dart'; import '../local/database.dart';
import '../models/project.dart'; import '../models/project.dart';
/// Projects repository with read-through caching for Tier 2 offline support. /// Projects repository with read-through caching (Phase 1+2) and offline-write
/// Mirrors the pattern in NotesRepository — see that file for full notes. /// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
/// ///
/// `getAll`'s sort/order/status query parameters are server-side filters; the /// `getAll`'s sort/order/status query parameters are server-side filters; the
/// cache stores the unfiltered list as last seen. Offline fallback returns /// cache stores the unfiltered list as last seen. Offline fallback returns
@@ -63,25 +63,106 @@ class ProjectsRepository {
String? color, String? color,
String status = 'active', String status = 'active',
}) async { }) async {
final project = await _api.create( try {
title: title, final project = await _api.create(
description: description, title: title,
goal: goal, description: description,
color: color, goal: goal,
status: status, color: color,
); status: status,
await _db.upsertProject(project); );
return project; await _db.upsertProject(project);
return project;
} on NetworkException {
final tempId = await _db.nextTempId();
final now = DateTime.now();
final optimistic = Project(
id: tempId,
title: title,
description: description,
goal: goal,
status: status,
color: color,
createdAt: now,
updatedAt: now,
);
await _db.upsertProject(optimistic);
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbCreate,
tempId: tempId,
payload: {
'title': title,
'description': description,
'goal': goal,
'color': color,
'status': status,
},
);
return optimistic;
}
} }
Future<Project> update(int id, Map<String, dynamic> fields) async { Future<Project> update(int id, Map<String, dynamic> fields) async {
final updated = await _api.update(id, fields); try {
await _db.upsertProject(updated); final updated = await _api.update(id, fields);
return updated; await _db.upsertProject(updated);
return updated;
} on NetworkException {
final cached = await _db.getProject(id);
if (cached == null) rethrow;
final optimistic = _applyProjectFields(cached, fields);
await _db.upsertProject(optimistic);
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
if (queued != null) {
await _db.updatePendingPayload(queued.id, fields);
return optimistic;
}
}
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbUpdate,
targetId: id,
payload: fields,
baselineUpdatedAt: cached.updatedAt,
);
return optimistic;
}
} }
Future<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete(id); try {
await _db.deleteProject(id); await _api.delete(id);
await _db.deleteProject(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainProjects, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteProject(id);
return;
}
await _db.deleteProject(id);
await _db.enqueuePending(
domain: kSyncDomainProjects,
verb: kWriteVerbDelete,
targetId: id,
);
}
} }
} }
Project _applyProjectFields(Project p, Map<String, dynamic> f) {
return Project(
id: p.id,
title: f['title'] as String? ?? p.title,
description:
f.containsKey('description') ? f['description'] as String? : p.description,
goal: f.containsKey('goal') ? f['goal'] as String? : p.goal,
status: f['status'] as String? ?? p.status,
color: f.containsKey('color') ? f['color'] as String? : p.color,
autoSummary: p.autoSummary,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
);
}
+120 -18
View File
@@ -3,8 +3,8 @@ import '../api/tasks_api.dart';
import '../local/database.dart'; import '../local/database.dart';
import '../models/task.dart'; import '../models/task.dart';
/// Tasks repository with read-through caching for Tier 2 offline support. /// Tasks repository with read-through caching (Phase 1+2) and offline-write
/// Mirrors the pattern in NotesRepository — see that file for full notes. /// queueing (Phase 3). See `notes_repository.dart` for the full pattern.
class TasksRepository { class TasksRepository {
final TasksApi _api; final TasksApi _api;
final FabledDatabase _db; final FabledDatabase _db;
@@ -68,27 +68,129 @@ class TasksRepository {
int? projectId, int? projectId,
int? parentId, int? parentId,
}) async { }) async {
final task = await _api.create( try {
title: title, final task = await _api.create(
description: description, title: title,
status: status, description: description,
priority: priority, status: status,
dueDate: dueDate, priority: priority,
projectId: projectId, dueDate: dueDate,
parentId: parentId, projectId: projectId,
); parentId: parentId,
await _db.upsertTask(task); );
return task; 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<Task> update(int id, Map<String, dynamic> fields) async { Future<Task> update(int id, Map<String, dynamic> fields) async {
final updated = await _api.update(id, fields); try {
await _db.upsertTask(updated); final updated = await _api.update(id, fields);
return updated; 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<void> delete(int id) async { Future<void> delete(int id) async {
await _api.delete(id); try {
await _db.deleteTask(id); await _api.delete(id);
await _db.deleteTask(id);
} on NetworkException {
if (id < 0) {
final queued = await _db.findQueuedCreate(kSyncDomainTasks, id);
if (queued != null) await _db.deletePending(queued.id);
await _db.deleteTask(id);
return;
}
await _db.deleteTask(id);
await _db.enqueuePending(
domain: kSyncDomainTasks,
verb: kWriteVerbDelete,
targetId: id,
);
}
} }
} }
/// Applies a partial-fields map (the same shape sent to the PUT endpoint)
/// to a cached Task so the UI can show the edit immediately.
Task _applyTaskFields(Task t, Map<String, dynamic> f) {
return Task(
id: t.id,
title: f['title'] as String? ?? t.title,
description:
f.containsKey('body') ? f['body'] as String? : t.description,
status: f.containsKey('status')
? TaskStatusExtension.fromString(f['status'] as String?)
: t.status,
priority: f.containsKey('priority')
? TaskPriorityExtension.fromString(f['priority'] as String?)
: t.priority,
dueDate: f.containsKey('due_date')
? (f['due_date'] is String
? DateTime.tryParse(f['due_date'] as String)
: null)
: t.dueDate,
projectId: f.containsKey('project_id')
? f['project_id'] as int?
: t.projectId,
milestoneId: f.containsKey('milestone_id')
? f['milestone_id'] as int?
: t.milestoneId,
parentId:
f.containsKey('parent_id') ? f['parent_id'] as int? : t.parentId,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
);
}
+385
View File
@@ -0,0 +1,385 @@
import 'dart:async';
import 'dart:convert';
import '../../core/exceptions.dart';
import '../api/events_api.dart';
import '../api/milestones_api.dart';
import '../api/notes_api.dart';
import '../api/projects_api.dart';
import '../api/tasks_api.dart';
import '../local/database.dart';
import '../models/note.dart';
import '../models/project.dart';
import '../models/task.dart';
/// Reasons a queued write was dropped during replay. Surfaced to the UI as
/// a one-time SnackBar so the user knows their offline edit didn't land.
enum QueueFailureReason { overwritten, missing, rejected }
class QueueFailure {
final QueueFailureReason reason;
final String domain;
final String? title;
final String? detail;
const QueueFailure({
required this.reason,
required this.domain,
this.title,
this.detail,
});
String get message {
final label = title?.isNotEmpty == true ? '"$title"' : 'an offline edit';
return switch (reason) {
QueueFailureReason.overwritten =>
'$label was overwritten by a newer change on the server.',
QueueFailureReason.missing =>
'$label was deleted on the server before your offline edit could be saved.',
QueueFailureReason.rejected =>
'Failed to save $label: ${detail ?? 'rejected by server.'}',
};
}
}
/// Drains the offline write queue (Phase 3). Owns the network → server
/// dispatch logic for every queued op; repos write to `pending_writes`,
/// this service replays them.
///
/// Replay semantics:
/// - oldest-first, single in-flight `replay()` call
/// - on `NetworkException` (or 5xx): leave op in place, stop the loop
/// - on conflict (server `updated_at` newer than op baseline): drop +
/// surface as `overwritten`
/// - on 404: for delete → silent success; for update → drop + `missing`
/// - on other 4xx: drop + `rejected`
class WriteQueue {
final FabledDatabase _db;
final NotesApi _notesApi;
final TasksApi _tasksApi;
final ProjectsApi _projectsApi;
final MilestonesApi _milestonesApi;
final EventsApi _eventsApi;
final StreamController<QueueFailure> _failures =
StreamController<QueueFailure>.broadcast();
bool _replaying = false;
WriteQueue({
required FabledDatabase db,
required NotesApi notesApi,
required TasksApi tasksApi,
required ProjectsApi projectsApi,
required MilestonesApi milestonesApi,
required EventsApi eventsApi,
}) : _db = db,
_notesApi = notesApi,
_tasksApi = tasksApi,
_projectsApi = projectsApi,
_milestonesApi = milestonesApi,
_eventsApi = eventsApi;
Stream<QueueFailure> get failures => _failures.stream;
void dispose() => _failures.close();
/// Drain the queue. Reentrant calls are no-ops while a replay is in
/// flight — the in-flight one will see new entries on its next iteration.
Future<void> replay() async {
if (_replaying) return;
_replaying = true;
try {
while (true) {
final ops = await _db.listPending();
if (ops.isEmpty) break;
final op = ops.first;
final keepGoing = await _replayOne(op);
if (!keepGoing) break;
}
} finally {
_replaying = false;
}
}
Future<bool> _replayOne(PendingWrite op) async {
final payload = jsonDecode(op.payloadJson) as Map<String, dynamic>;
await _db.incrementPendingTries(op.id);
try {
switch (op.domain) {
case kSyncDomainNotes:
await _replayNote(op, payload);
case kSyncDomainTasks:
await _replayTask(op, payload);
case kSyncDomainProjects:
await _replayProject(op, payload);
case kSyncDomainMilestones:
await _replayMilestone(op, payload);
case kSyncDomainEvents:
await _replayEvent(op, payload);
default:
// Unknown domain — drop so we don't loop forever.
await _db.deletePending(op.id);
return true;
}
await _db.deletePending(op.id);
return true;
} on NetworkException catch (e) {
// Still offline — leave op for next replay trigger.
await _db.markPendingFailed(op.id, e.message);
return false;
} on ServerException catch (e) {
if (e.statusCode >= 500) {
// Likely transient — keep + stop the loop, retry next time.
await _db.markPendingFailed(op.id, e.message);
return false;
}
// 4xx — non-retryable. Drop + surface.
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.rejected,
domain: op.domain,
title: payload['title'] as String?,
detail: e.message,
));
return true;
} on NotFoundException {
// Target gone server-side. Update → tell user; delete → silent success.
await _db.deletePending(op.id);
if (op.verb != kWriteVerbDelete) {
_failures.add(QueueFailure(
reason: QueueFailureReason.missing,
domain: op.domain,
title: payload['title'] as String?,
));
}
// Clean the cache so the optimistic row doesn't linger.
await _evictCache(op.domain, op.targetId ?? op.tempId);
return true;
} on _OverwrittenSignal catch (s) {
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.overwritten,
domain: op.domain,
title: s.title,
));
return true;
} on AppException catch (e) {
// Auth or other — surface and drop so the queue can drain.
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.rejected,
domain: op.domain,
title: payload['title'] as String?,
detail: e.message,
));
return true;
}
}
Future<void> _evictCache(String domain, int? id) async {
if (id == null) return;
switch (domain) {
case kSyncDomainNotes:
await _db.deleteNote(id);
case kSyncDomainTasks:
await _db.deleteTask(id);
case kSyncDomainProjects:
await _db.deleteProject(id);
case kSyncDomainMilestones:
await _db.deleteMilestone(id);
case kSyncDomainEvents:
await _db.deleteEvent(id);
}
}
// ── Notes ──────────────────────────────────────────────────────────────────
Future<void> _replayNote(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final note = await _notesApi.create(
p['title'] as String? ?? '',
p['body'] as String? ?? '',
tags: _stringList(p['tags']),
projectId: p['project_id'] as int?,
noteType: p['note_type'] as String? ?? 'note',
);
if (op.tempId != null) await _db.deleteNote(op.tempId!);
await _db.upsertNote(note);
case kWriteVerbUpdate:
await _conflictGuard<Note>(
baseline: op.baselineUpdatedAt,
fetch: () => _notesApi.getOne(op.targetId!),
updatedAt: (n) => n.updatedAt,
title: (n) => n.title,
syncCache: (n) => _db.upsertNote(n),
);
final note = await _notesApi.update(
op.targetId!,
p['title'] as String? ?? '',
p['body'] as String? ?? '',
tags: _stringList(p['tags']),
projectId: p['project_id'] as int?,
clearProject: p['clear_project'] as bool? ?? false,
noteType: p['note_type'] as String? ?? 'note',
);
await _db.upsertNote(note);
case kWriteVerbDelete:
try {
await _notesApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteNote(op.targetId!);
}
}
// ── Tasks ──────────────────────────────────────────────────────────────────
Future<void> _replayTask(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final task = await _tasksApi.create(
title: p['title'] as String? ?? '',
description: p['description'] as String?,
status: TaskStatusExtension.fromString(p['status'] as String?),
priority: TaskPriorityExtension.fromString(p['priority'] as String?),
dueDate: _parseDate(p['due_date']),
projectId: p['project_id'] as int?,
parentId: p['parent_id'] as int?,
);
if (op.tempId != null) await _db.deleteTask(op.tempId!);
await _db.upsertTask(task);
case kWriteVerbUpdate:
await _conflictGuard<Task>(
baseline: op.baselineUpdatedAt,
fetch: () => _tasksApi.getOne(op.targetId!),
updatedAt: (t) => t.updatedAt,
title: (t) => t.title,
syncCache: (t) => _db.upsertTask(t),
);
final task = await _tasksApi.update(op.targetId!, p);
await _db.upsertTask(task);
case kWriteVerbDelete:
try {
await _tasksApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteTask(op.targetId!);
}
}
// ── Projects ───────────────────────────────────────────────────────────────
Future<void> _replayProject(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final project = await _projectsApi.create(
title: p['title'] as String? ?? '',
description: p['description'] as String?,
goal: p['goal'] as String?,
color: p['color'] as String?,
status: p['status'] as String? ?? 'active',
);
if (op.tempId != null) await _db.deleteProject(op.tempId!);
await _db.upsertProject(project);
case kWriteVerbUpdate:
await _conflictGuard<Project>(
baseline: op.baselineUpdatedAt,
fetch: () => _projectsApi.getOne(op.targetId!),
updatedAt: (proj) => proj.updatedAt,
title: (proj) => proj.title,
syncCache: (proj) => _db.upsertProject(proj),
);
final project = await _projectsApi.update(op.targetId!, p);
await _db.upsertProject(project);
case kWriteVerbDelete:
try {
await _projectsApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteProject(op.targetId!);
}
}
// ── Milestones ─────────────────────────────────────────────────────────────
Future<void> _replayMilestone(
PendingWrite op, Map<String, dynamic> p) async {
final projectId = p['project_id'] as int;
switch (op.verb) {
case kWriteVerbCreate:
final milestone = await _milestonesApi.create(
projectId,
title: p['title'] as String? ?? '',
description: p['description'] as String?,
orderIndex: p['order_index'] as int? ?? 0,
);
if (op.tempId != null) await _db.deleteMilestone(op.tempId!);
await _db.upsertMilestone(milestone);
case kWriteVerbUpdate:
// Milestones API has no getOne — skip the conflict pre-check and
// let the PUT apply unconditionally. If two clients fight over the
// same milestone, last-writer-wins is acceptable here.
final milestone = await _milestonesApi.update(
projectId,
op.targetId!,
Map<String, dynamic>.from(p)..remove('project_id'),
);
await _db.upsertMilestone(milestone);
case kWriteVerbDelete:
try {
await _milestonesApi.delete(projectId, op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteMilestone(op.targetId!);
}
}
// ── Events ─────────────────────────────────────────────────────────────────
Future<void> _replayEvent(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final event = await _eventsApi.createEvent(p);
if (op.tempId != null) await _db.deleteEvent(op.tempId!);
await _db.upsertEvent(event);
case kWriteVerbUpdate:
// Events API also has no getOne — skip the pre-check; last-writer-wins.
final event = await _eventsApi.updateEvent(op.targetId!, p);
await _db.upsertEvent(event);
case kWriteVerbDelete:
try {
await _eventsApi.deleteEvent(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteEvent(op.targetId!);
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
/// Server-wins conflict guard. Fetch the target, compare its `updated_at`
/// against the baseline captured when the user made the offline edit;
/// if the server is newer, sync cache and signal the caller to drop.
Future<void> _conflictGuard<T>({
required DateTime? baseline,
required Future<T> Function() fetch,
required DateTime Function(T) updatedAt,
required String Function(T) title,
required Future<void> Function(T) syncCache,
}) async {
if (baseline == null) return;
final server = await fetch();
if (updatedAt(server).isAfter(baseline)) {
await syncCache(server);
throw _OverwrittenSignal(title(server));
}
}
List<String> _stringList(dynamic raw) =>
raw is List ? raw.map((e) => e.toString()).toList() : const [];
DateTime? _parseDate(dynamic raw) {
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw);
return null;
}
}
class _OverwrittenSignal implements Exception {
final String title;
const _OverwrittenSignal(this.title);
}
+25
View File
@@ -25,6 +25,7 @@ import '../data/repositories/milestones_repository.dart';
import '../data/repositories/notes_repository.dart'; import '../data/repositories/notes_repository.dart';
import '../data/repositories/projects_repository.dart'; import '../data/repositories/projects_repository.dart';
import '../data/repositories/tasks_repository.dart'; import '../data/repositories/tasks_repository.dart';
import '../data/repositories/write_queue.dart';
import 'settings_provider.dart'; import 'settings_provider.dart';
final cookieJarProvider = Provider<PersistCookieJar>((ref) { final cookieJarProvider = Provider<PersistCookieJar>((ref) {
@@ -148,3 +149,27 @@ final eventsRepositoryProvider = Provider<EventsRepository>((ref) {
ref.watch(fabledDatabaseProvider), 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;
});
+10 -2
View File
@@ -84,9 +84,17 @@ class _OfflineBannerState extends ConsumerState<OfflineBanner> {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final ago = _lastSync == null ? null : _relativeAgo(_lastSync!); 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 — showing cached data.'
: 'Offline — last sync $ago.'; : '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( return Material(
color: scheme.errorContainer, color: scheme.errorContainer,
@@ -117,7 +125,7 @@ class _OfflineBannerState extends ConsumerState<OfflineBanner> {
height: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Text('Retry'), : Text(retryLabel),
), ),
], ],
), ),
+33
View File
@@ -1,5 +1,6 @@
import 'package:fabled_app/data/models/calendar_event.dart'; import 'package:fabled_app/data/models/calendar_event.dart';
import 'package:fabled_app/data/api/voice_api.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/providers/voice_provider.dart';
import 'package:fabled_app/data/models/knowledge_item.dart'; import 'package:fabled_app/data/models/knowledge_item.dart';
import 'package:fabled_app/data/models/note.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'));
});
});
} }