7df7b5ff85
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>
740 lines
25 KiB
Dart
740 lines
25 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
|
|
|
|
import '../models/calendar_event.dart';
|
|
import '../models/conversation.dart';
|
|
import '../models/milestone.dart';
|
|
import '../models/note.dart';
|
|
import '../models/project.dart';
|
|
import '../models/task.dart';
|
|
|
|
part 'database.g.dart';
|
|
|
|
// ── Tables ───────────────────────────────────────────────────────────────────
|
|
//
|
|
// Each cached_* table mirrors the corresponding model 1:1. Tags / list fields
|
|
// are stored as JSON-encoded text; SQLite lacks a native list type and
|
|
// Drift's typed converters add ceremony we don't need for read-side caching.
|
|
// `cachedAt` tracks when the row was last written from a successful API
|
|
// response; the SyncMetadata table tracks the per-domain "last bulk fetch"
|
|
// timestamp used by the OfflineBanner.
|
|
|
|
class CachedNotes extends Table {
|
|
IntColumn get id => integer()();
|
|
TextColumn get title => text()();
|
|
TextColumn get body => text()();
|
|
TextColumn get tagsJson => text().withDefault(const Constant('[]'))();
|
|
TextColumn get noteType => text().withDefault(const Constant('note'))();
|
|
IntColumn get projectId => integer().nullable()();
|
|
IntColumn get milestoneId => integer().nullable()();
|
|
DateTimeColumn get createdAt => dateTime()();
|
|
DateTimeColumn get updatedAt => dateTime()();
|
|
DateTimeColumn get cachedAt =>
|
|
dateTime().clientDefault(() => DateTime.now())();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
class CachedTasks extends Table {
|
|
IntColumn get id => integer()();
|
|
TextColumn get title => text()();
|
|
TextColumn get description => text().nullable()();
|
|
TextColumn get status => text()();
|
|
TextColumn get priority => text()();
|
|
DateTimeColumn get dueDate => dateTime().nullable()();
|
|
IntColumn get projectId => integer().nullable()();
|
|
IntColumn get milestoneId => integer().nullable()();
|
|
IntColumn get parentId => integer().nullable()();
|
|
DateTimeColumn get createdAt => dateTime()();
|
|
DateTimeColumn get updatedAt => dateTime()();
|
|
DateTimeColumn get cachedAt =>
|
|
dateTime().clientDefault(() => DateTime.now())();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
class CachedProjects extends Table {
|
|
IntColumn get id => integer()();
|
|
TextColumn get title => text()();
|
|
TextColumn get description => text().nullable()();
|
|
TextColumn get goal => text().nullable()();
|
|
TextColumn get status => text()();
|
|
TextColumn get color => text().nullable()();
|
|
TextColumn get autoSummary => text().nullable()();
|
|
DateTimeColumn get createdAt => dateTime()();
|
|
DateTimeColumn get updatedAt => dateTime()();
|
|
DateTimeColumn get cachedAt =>
|
|
dateTime().clientDefault(() => DateTime.now())();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
class CachedMilestones extends Table {
|
|
IntColumn get id => integer()();
|
|
IntColumn get projectId => integer()();
|
|
TextColumn get title => text()();
|
|
TextColumn get description => text().nullable()();
|
|
TextColumn get status => text()();
|
|
IntColumn get orderIndex => integer().withDefault(const Constant(0))();
|
|
IntColumn get total => integer().withDefault(const Constant(0))();
|
|
IntColumn get completed => integer().withDefault(const Constant(0))();
|
|
RealColumn get pct => real().withDefault(const Constant(0.0))();
|
|
DateTimeColumn get createdAt => dateTime()();
|
|
DateTimeColumn get updatedAt => dateTime()();
|
|
DateTimeColumn get cachedAt =>
|
|
dateTime().clientDefault(() => DateTime.now())();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
class CachedCalendarEvents extends Table {
|
|
IntColumn get id => integer()();
|
|
TextColumn get title => text()();
|
|
DateTimeColumn get startDt => dateTime()();
|
|
DateTimeColumn get endDt => dateTime().nullable()();
|
|
BoolColumn get allDay => boolean().withDefault(const Constant(false))();
|
|
TextColumn get description => text().withDefault(const Constant(''))();
|
|
TextColumn get location => text().withDefault(const Constant(''))();
|
|
TextColumn get color => text().withDefault(const Constant(''))();
|
|
TextColumn get recurrence => text().nullable()();
|
|
IntColumn get projectId => integer().nullable()();
|
|
IntColumn get reminderMinutes => integer().nullable()();
|
|
DateTimeColumn get cachedAt =>
|
|
dateTime().clientDefault(() => DateTime.now())();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
class CachedConversations extends Table {
|
|
IntColumn get id => integer()();
|
|
TextColumn get title => text()();
|
|
DateTimeColumn get createdAt => dateTime()();
|
|
DateTimeColumn get updatedAt => dateTime()();
|
|
DateTimeColumn get cachedAt =>
|
|
dateTime().clientDefault(() => DateTime.now())();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
class SyncMetadata extends Table {
|
|
TextColumn get domain => text()();
|
|
DateTimeColumn get lastSyncedAt => dateTime()();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {domain};
|
|
}
|
|
|
|
/// Phase 3 — generic offline-write queue. One row per pending API call;
|
|
/// drained in oldest-first order when the device comes back online.
|
|
///
|
|
/// `targetId` is the server id for updates/deletes; `tempId` is the
|
|
/// client-allocated negative placeholder for offline creates. `payloadJson`
|
|
/// is the typed args the API method needs (verb-specific shape).
|
|
/// `baselineUpdatedAt` snapshots the cached row's `updated_at` at edit
|
|
/// time so the replayer can drop the op if the server has moved on
|
|
/// (server-wins conflict resolution).
|
|
class PendingWrites extends Table {
|
|
IntColumn get id => integer().autoIncrement()();
|
|
TextColumn get domain => text()();
|
|
TextColumn get verb => text()();
|
|
IntColumn get targetId => integer().nullable()();
|
|
IntColumn get tempId => integer().nullable()();
|
|
TextColumn get payloadJson => text().withDefault(const Constant('{}'))();
|
|
DateTimeColumn get baselineUpdatedAt => dateTime().nullable()();
|
|
DateTimeColumn get createdAt =>
|
|
dateTime().clientDefault(() => DateTime.now())();
|
|
IntColumn get tries => integer().withDefault(const Constant(0))();
|
|
TextColumn get lastError => text().nullable()();
|
|
}
|
|
|
|
const String kSyncDomainNotes = 'notes';
|
|
const String kSyncDomainTasks = 'tasks';
|
|
const String kSyncDomainProjects = 'projects';
|
|
const String kSyncDomainMilestones = 'milestones';
|
|
const String kSyncDomainEvents = 'events';
|
|
const String kSyncDomainConversations = 'conversations';
|
|
|
|
const String kWriteVerbCreate = 'create';
|
|
const String kWriteVerbUpdate = 'update';
|
|
const String kWriteVerbDelete = 'delete';
|
|
|
|
@DriftDatabase(tables: [
|
|
CachedNotes,
|
|
CachedTasks,
|
|
CachedProjects,
|
|
CachedMilestones,
|
|
CachedCalendarEvents,
|
|
CachedConversations,
|
|
SyncMetadata,
|
|
PendingWrites,
|
|
])
|
|
class FabledDatabase extends _$FabledDatabase {
|
|
FabledDatabase() : super(_openConnection());
|
|
FabledDatabase.forTesting(super.executor);
|
|
|
|
@override
|
|
int get schemaVersion => 3;
|
|
|
|
@override
|
|
MigrationStrategy get migration => MigrationStrategy(
|
|
onCreate: (m) => m.createAll(),
|
|
onUpgrade: (m, from, to) async {
|
|
// v1 → v2: added the per-domain caches beyond notes.
|
|
if (from < 2) {
|
|
await m.createTable(cachedTasks);
|
|
await m.createTable(cachedProjects);
|
|
await m.createTable(cachedMilestones);
|
|
await m.createTable(cachedCalendarEvents);
|
|
await m.createTable(cachedConversations);
|
|
}
|
|
// v2 → v3: added the offline write queue.
|
|
if (from < 3) {
|
|
await m.createTable(pendingWrites);
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── Notes ────────────────────────────────────────────────────────────────
|
|
|
|
Future<List<Note>> getAllNotes() async {
|
|
final rows = await (select(cachedNotes)
|
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
|
.get();
|
|
return rows.map(_noteFromRow).toList();
|
|
}
|
|
|
|
Future<Note?> getNote(int id) async {
|
|
final row = await (select(cachedNotes)..where((t) => t.id.equals(id)))
|
|
.getSingleOrNull();
|
|
return row == null ? null : _noteFromRow(row);
|
|
}
|
|
|
|
Future<void> replaceAllNotes(List<Note> notes) async {
|
|
await transaction(() async {
|
|
await delete(cachedNotes).go();
|
|
if (notes.isNotEmpty) {
|
|
await batch((b) {
|
|
b.insertAll(cachedNotes, notes.map(_noteToCompanion).toList());
|
|
});
|
|
}
|
|
await _setLastSyncIn(kSyncDomainNotes, DateTime.now());
|
|
});
|
|
}
|
|
|
|
Future<void> upsertNote(Note note) async {
|
|
await into(cachedNotes).insertOnConflictUpdate(_noteToCompanion(note));
|
|
}
|
|
|
|
Future<void> deleteNote(int id) async {
|
|
await (delete(cachedNotes)..where((t) => t.id.equals(id))).go();
|
|
}
|
|
|
|
// ── Tasks ────────────────────────────────────────────────────────────────
|
|
|
|
Future<List<Task>> getAllTasks() async {
|
|
final rows = await (select(cachedTasks)
|
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
|
.get();
|
|
return rows.map(_taskFromRow).toList();
|
|
}
|
|
|
|
Future<Task?> getTask(int id) async {
|
|
final row = await (select(cachedTasks)..where((t) => t.id.equals(id)))
|
|
.getSingleOrNull();
|
|
return row == null ? null : _taskFromRow(row);
|
|
}
|
|
|
|
Future<List<Task>> getTasksByProject(int projectId) async {
|
|
final rows = await (select(cachedTasks)
|
|
..where((t) => t.projectId.equals(projectId))
|
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
|
.get();
|
|
return rows.map(_taskFromRow).toList();
|
|
}
|
|
|
|
Future<List<Task>> getSubTasks(int parentId) async {
|
|
final rows = await (select(cachedTasks)
|
|
..where((t) => t.parentId.equals(parentId))
|
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
|
.get();
|
|
return rows.map(_taskFromRow).toList();
|
|
}
|
|
|
|
Future<void> replaceAllTasks(List<Task> tasks) async {
|
|
await transaction(() async {
|
|
await delete(cachedTasks).go();
|
|
if (tasks.isNotEmpty) {
|
|
await batch((b) {
|
|
b.insertAll(cachedTasks, tasks.map(_taskToCompanion).toList());
|
|
});
|
|
}
|
|
await _setLastSyncIn(kSyncDomainTasks, DateTime.now());
|
|
});
|
|
}
|
|
|
|
Future<void> upsertTask(Task task) async {
|
|
await into(cachedTasks).insertOnConflictUpdate(_taskToCompanion(task));
|
|
}
|
|
|
|
Future<void> deleteTask(int id) async {
|
|
await (delete(cachedTasks)..where((t) => t.id.equals(id))).go();
|
|
}
|
|
|
|
// ── Projects ─────────────────────────────────────────────────────────────
|
|
|
|
Future<List<Project>> getAllProjects() async {
|
|
final rows = await (select(cachedProjects)
|
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
|
.get();
|
|
return rows.map(_projectFromRow).toList();
|
|
}
|
|
|
|
Future<Project?> getProject(int id) async {
|
|
final row = await (select(cachedProjects)..where((t) => t.id.equals(id)))
|
|
.getSingleOrNull();
|
|
return row == null ? null : _projectFromRow(row);
|
|
}
|
|
|
|
Future<void> replaceAllProjects(List<Project> projects) async {
|
|
await transaction(() async {
|
|
await delete(cachedProjects).go();
|
|
if (projects.isNotEmpty) {
|
|
await batch((b) {
|
|
b.insertAll(
|
|
cachedProjects,
|
|
projects.map(_projectToCompanion).toList(),
|
|
);
|
|
});
|
|
}
|
|
await _setLastSyncIn(kSyncDomainProjects, DateTime.now());
|
|
});
|
|
}
|
|
|
|
Future<void> upsertProject(Project project) async {
|
|
await into(cachedProjects)
|
|
.insertOnConflictUpdate(_projectToCompanion(project));
|
|
}
|
|
|
|
Future<void> deleteProject(int id) async {
|
|
await (delete(cachedProjects)..where((t) => t.id.equals(id))).go();
|
|
}
|
|
|
|
// ── Milestones ───────────────────────────────────────────────────────────
|
|
|
|
Future<List<Milestone>> getMilestonesForProject(int projectId) async {
|
|
final rows = await (select(cachedMilestones)
|
|
..where((t) => t.projectId.equals(projectId))
|
|
..orderBy([(t) => OrderingTerm.asc(t.orderIndex)]))
|
|
.get();
|
|
return rows.map(_milestoneFromRow).toList();
|
|
}
|
|
|
|
Future<void> replaceMilestonesForProject(
|
|
int projectId,
|
|
List<Milestone> milestones,
|
|
) async {
|
|
await transaction(() async {
|
|
await (delete(cachedMilestones)
|
|
..where((t) => t.projectId.equals(projectId)))
|
|
.go();
|
|
if (milestones.isNotEmpty) {
|
|
await batch((b) {
|
|
b.insertAll(
|
|
cachedMilestones,
|
|
milestones.map(_milestoneToCompanion).toList(),
|
|
);
|
|
});
|
|
}
|
|
await _setLastSyncIn(kSyncDomainMilestones, DateTime.now());
|
|
});
|
|
}
|
|
|
|
Future<void> upsertMilestone(Milestone milestone) async {
|
|
await into(cachedMilestones)
|
|
.insertOnConflictUpdate(_milestoneToCompanion(milestone));
|
|
}
|
|
|
|
Future<void> deleteMilestone(int id) async {
|
|
await (delete(cachedMilestones)..where((t) => t.id.equals(id))).go();
|
|
}
|
|
|
|
// ── Calendar events ──────────────────────────────────────────────────────
|
|
|
|
/// Returns events whose start_dt falls within [from, to). Recurrence
|
|
/// expansion still happens server-side; a stored event that has a
|
|
/// non-null recurrence rule will only have one row in cache (its
|
|
/// canonical instance), so cached date-range queries on recurring
|
|
/// events are best-effort and may miss future occurrences.
|
|
Future<List<CalendarEvent>> getEventsInRange(
|
|
DateTime from, DateTime to) async {
|
|
final rows = await (select(cachedCalendarEvents)
|
|
..where((t) =>
|
|
t.startDt.isBiggerOrEqualValue(from) &
|
|
t.startDt.isSmallerThanValue(to))
|
|
..orderBy([(t) => OrderingTerm.asc(t.startDt)]))
|
|
.get();
|
|
return rows.map(_eventFromRow).toList();
|
|
}
|
|
|
|
/// Replace the cache for the given range. Events outside [from, to)
|
|
/// are left alone — this matches the API's range-scoped semantics so
|
|
/// repeated fetches over disjoint ranges don't clobber each other.
|
|
Future<void> replaceEventsInRange(
|
|
DateTime from,
|
|
DateTime to,
|
|
List<CalendarEvent> events,
|
|
) async {
|
|
await transaction(() async {
|
|
await (delete(cachedCalendarEvents)
|
|
..where((t) =>
|
|
t.startDt.isBiggerOrEqualValue(from) &
|
|
t.startDt.isSmallerThanValue(to)))
|
|
.go();
|
|
if (events.isNotEmpty) {
|
|
await batch((b) {
|
|
b.insertAll(
|
|
cachedCalendarEvents,
|
|
events.map(_eventToCompanion).toList(),
|
|
);
|
|
});
|
|
}
|
|
await _setLastSyncIn(kSyncDomainEvents, DateTime.now());
|
|
});
|
|
}
|
|
|
|
Future<void> upsertEvent(CalendarEvent event) async {
|
|
await into(cachedCalendarEvents)
|
|
.insertOnConflictUpdate(_eventToCompanion(event));
|
|
}
|
|
|
|
Future<void> deleteEvent(int id) async {
|
|
await (delete(cachedCalendarEvents)..where((t) => t.id.equals(id))).go();
|
|
}
|
|
|
|
// ── Conversations (chat list — not messages) ─────────────────────────────
|
|
|
|
Future<List<Conversation>> getAllConversations() async {
|
|
final rows = await (select(cachedConversations)
|
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
|
.get();
|
|
return rows.map(_conversationFromRow).toList();
|
|
}
|
|
|
|
Future<void> replaceAllConversations(
|
|
List<Conversation> conversations) async {
|
|
await transaction(() async {
|
|
await delete(cachedConversations).go();
|
|
if (conversations.isNotEmpty) {
|
|
await batch((b) {
|
|
b.insertAll(
|
|
cachedConversations,
|
|
conversations.map(_conversationToCompanion).toList(),
|
|
);
|
|
});
|
|
}
|
|
await _setLastSyncIn(kSyncDomainConversations, DateTime.now());
|
|
});
|
|
}
|
|
|
|
Future<void> deleteConversation(int id) async {
|
|
await (delete(cachedConversations)..where((t) => t.id.equals(id))).go();
|
|
}
|
|
|
|
// ── Sync metadata ────────────────────────────────────────────────────────
|
|
|
|
Future<DateTime?> getLastSync(String domain) async {
|
|
final row = await (select(syncMetadata)
|
|
..where((t) => t.domain.equals(domain)))
|
|
.getSingleOrNull();
|
|
return row?.lastSyncedAt;
|
|
}
|
|
|
|
/// Most recent sync across all domains. Used by the OfflineBanner so the
|
|
/// hint reads as "your data was current as of X" regardless of which
|
|
/// screen the user happens to be on.
|
|
Future<DateTime?> getLatestSync() async {
|
|
final row = await (select(syncMetadata)
|
|
..orderBy([(t) => OrderingTerm.desc(t.lastSyncedAt)])
|
|
..limit(1))
|
|
.getSingleOrNull();
|
|
return row?.lastSyncedAt;
|
|
}
|
|
|
|
Future<void> _setLastSyncIn(String domain, DateTime timestamp) {
|
|
return into(syncMetadata).insertOnConflictUpdate(
|
|
SyncMetadataCompanion(
|
|
domain: Value(domain),
|
|
lastSyncedAt: Value(timestamp),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── 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 ──────────────────────────────────────────────────
|
|
|
|
CachedNotesCompanion _noteToCompanion(Note n) => CachedNotesCompanion(
|
|
id: Value(n.id),
|
|
title: Value(n.title),
|
|
body: Value(n.body),
|
|
tagsJson: Value(jsonEncode(n.tags)),
|
|
noteType: Value(n.noteType),
|
|
projectId: Value(n.projectId),
|
|
milestoneId: Value(n.milestoneId),
|
|
createdAt: Value(n.createdAt),
|
|
updatedAt: Value(n.updatedAt),
|
|
);
|
|
|
|
Note _noteFromRow(CachedNote r) => Note(
|
|
id: r.id,
|
|
title: r.title,
|
|
body: r.body,
|
|
tags: (jsonDecode(r.tagsJson) as List<dynamic>).cast<String>(),
|
|
noteType: r.noteType,
|
|
projectId: r.projectId,
|
|
milestoneId: r.milestoneId,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
);
|
|
|
|
CachedTasksCompanion _taskToCompanion(Task t) => CachedTasksCompanion(
|
|
id: Value(t.id),
|
|
title: Value(t.title),
|
|
description: Value(t.description),
|
|
status: Value(t.status.value),
|
|
priority: Value(t.priority.value),
|
|
dueDate: Value(t.dueDate),
|
|
projectId: Value(t.projectId),
|
|
milestoneId: Value(t.milestoneId),
|
|
parentId: Value(t.parentId),
|
|
createdAt: Value(t.createdAt),
|
|
updatedAt: Value(t.updatedAt),
|
|
);
|
|
|
|
Task _taskFromRow(CachedTask r) => Task(
|
|
id: r.id,
|
|
title: r.title,
|
|
description: r.description,
|
|
status: TaskStatusExtension.fromString(r.status),
|
|
priority: TaskPriorityExtension.fromString(r.priority),
|
|
dueDate: r.dueDate,
|
|
projectId: r.projectId,
|
|
milestoneId: r.milestoneId,
|
|
parentId: r.parentId,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
);
|
|
|
|
CachedProjectsCompanion _projectToCompanion(Project p) =>
|
|
CachedProjectsCompanion(
|
|
id: Value(p.id),
|
|
title: Value(p.title),
|
|
description: Value(p.description),
|
|
goal: Value(p.goal),
|
|
status: Value(p.status),
|
|
color: Value(p.color),
|
|
autoSummary: Value(p.autoSummary),
|
|
createdAt: Value(p.createdAt),
|
|
updatedAt: Value(p.updatedAt),
|
|
);
|
|
|
|
Project _projectFromRow(CachedProject r) => Project(
|
|
id: r.id,
|
|
title: r.title,
|
|
description: r.description,
|
|
goal: r.goal,
|
|
status: r.status,
|
|
color: r.color,
|
|
autoSummary: r.autoSummary,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
);
|
|
|
|
CachedMilestonesCompanion _milestoneToCompanion(Milestone m) =>
|
|
CachedMilestonesCompanion(
|
|
id: Value(m.id),
|
|
projectId: Value(m.projectId),
|
|
title: Value(m.title),
|
|
description: Value(m.description),
|
|
status: Value(m.status),
|
|
orderIndex: Value(m.orderIndex),
|
|
total: Value(m.total),
|
|
completed: Value(m.completed),
|
|
pct: Value(m.pct),
|
|
createdAt: Value(m.createdAt),
|
|
updatedAt: Value(m.updatedAt),
|
|
);
|
|
|
|
Milestone _milestoneFromRow(CachedMilestone r) => Milestone(
|
|
id: r.id,
|
|
projectId: r.projectId,
|
|
title: r.title,
|
|
description: r.description,
|
|
status: r.status,
|
|
orderIndex: r.orderIndex,
|
|
total: r.total,
|
|
completed: r.completed,
|
|
pct: r.pct,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
);
|
|
|
|
CachedCalendarEventsCompanion _eventToCompanion(CalendarEvent e) =>
|
|
CachedCalendarEventsCompanion(
|
|
id: Value(e.id),
|
|
title: Value(e.title),
|
|
startDt: Value(e.startDt),
|
|
endDt: Value(e.endDt),
|
|
allDay: Value(e.allDay),
|
|
description: Value(e.description),
|
|
location: Value(e.location),
|
|
color: Value(e.color),
|
|
recurrence: Value(e.recurrence),
|
|
projectId: Value(e.projectId),
|
|
reminderMinutes: Value(e.reminderMinutes),
|
|
);
|
|
|
|
CalendarEvent _eventFromRow(CachedCalendarEvent r) => CalendarEvent(
|
|
id: r.id,
|
|
title: r.title,
|
|
startDt: r.startDt,
|
|
endDt: r.endDt,
|
|
allDay: r.allDay,
|
|
description: r.description,
|
|
location: r.location,
|
|
color: r.color,
|
|
recurrence: r.recurrence,
|
|
projectId: r.projectId,
|
|
reminderMinutes: r.reminderMinutes,
|
|
);
|
|
|
|
CachedConversationsCompanion _conversationToCompanion(Conversation c) =>
|
|
CachedConversationsCompanion(
|
|
id: Value(c.id),
|
|
title: Value(c.title),
|
|
createdAt: Value(c.createdAt),
|
|
updatedAt: Value(c.updatedAt),
|
|
);
|
|
|
|
Conversation _conversationFromRow(CachedConversation r) => Conversation(
|
|
id: r.id,
|
|
title: r.title,
|
|
createdAt: r.createdAt,
|
|
updatedAt: r.updatedAt,
|
|
);
|
|
|
|
LazyDatabase _openConnection() {
|
|
return LazyDatabase(() async {
|
|
if (Platform.isAndroid) {
|
|
await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
|
|
}
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
final file = File(p.join(dir.path, 'fabled_cache.sqlite'));
|
|
return NativeDatabase.createInBackground(file);
|
|
});
|
|
}
|