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/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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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/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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user