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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic>) {
|
||||
for (final key in const ['detail', 'error', 'message']) {
|
||||
final value = data[key];
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,29 @@ class SyncMetadata extends Table {
|
||||
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 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<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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
_$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<TableInfo<Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||
@@ -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<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 {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<CalendarEvent> createEvent(Map<String, dynamic> 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<CalendarEvent> updateEvent(
|
||||
int id, Map<String, dynamic> 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<void> 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<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;
|
||||
}
|
||||
|
||||
@@ -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<Milestone> update(
|
||||
@@ -55,13 +87,78 @@ class MilestonesRepository {
|
||||
int milestoneId,
|
||||
Map<String, dynamic> 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 = <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 {
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Note> 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 = <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 {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Project> update(int id, Map<String, dynamic> 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<void> 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<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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Task> update(int id, Map<String, dynamic> 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<void> 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<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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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<PersistCookieJar>((ref) {
|
||||
@@ -148,3 +149,27 @@ final eventsRepositoryProvider = Provider<EventsRepository>((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<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;
|
||||
});
|
||||
|
||||
@@ -84,9 +84,17 @@ class _OfflineBannerState extends ConsumerState<OfflineBanner> {
|
||||
|
||||
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<OfflineBanner> {
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Retry'),
|
||||
: Text(retryLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user