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 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 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 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 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 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 get primaryKey => {id}; } class SyncMetadata extends Table { TextColumn get domain => text()(); DateTimeColumn get lastSyncedAt => dateTime()(); @override Set get primaryKey => {domain}; } /// Phase 3 — generic offline-write queue. One row per pending API call; /// drained in oldest-first order when the device comes back online. /// /// `targetId` is the server id for updates/deletes; `tempId` is the /// client-allocated negative placeholder for offline creates. `payloadJson` /// is the typed args the API method needs (verb-specific shape). /// `baselineUpdatedAt` snapshots the cached row's `updated_at` at edit /// time so the replayer can drop the op if the server has moved on /// (server-wins conflict resolution). class PendingWrites extends Table { IntColumn get id => integer().autoIncrement()(); TextColumn get domain => text()(); TextColumn get verb => text()(); IntColumn get targetId => integer().nullable()(); IntColumn get tempId => integer().nullable()(); TextColumn get payloadJson => text().withDefault(const Constant('{}'))(); DateTimeColumn get baselineUpdatedAt => dateTime().nullable()(); DateTimeColumn get createdAt => dateTime().clientDefault(() => DateTime.now())(); IntColumn get tries => integer().withDefault(const Constant(0))(); TextColumn get lastError => text().nullable()(); } const String kSyncDomainNotes = 'notes'; const String kSyncDomainTasks = 'tasks'; const String kSyncDomainProjects = 'projects'; 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> getAllNotes() async { final rows = await (select(cachedNotes) ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) .get(); return rows.map(_noteFromRow).toList(); } Future getNote(int id) async { final row = await (select(cachedNotes)..where((t) => t.id.equals(id))) .getSingleOrNull(); return row == null ? null : _noteFromRow(row); } Future replaceAllNotes(List 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 upsertNote(Note note) async { await into(cachedNotes).insertOnConflictUpdate(_noteToCompanion(note)); } Future deleteNote(int id) async { await (delete(cachedNotes)..where((t) => t.id.equals(id))).go(); } // ── Tasks ──────────────────────────────────────────────────────────────── Future> getAllTasks() async { final rows = await (select(cachedTasks) ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) .get(); return rows.map(_taskFromRow).toList(); } Future getTask(int id) async { final row = await (select(cachedTasks)..where((t) => t.id.equals(id))) .getSingleOrNull(); return row == null ? null : _taskFromRow(row); } Future> 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> 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 replaceAllTasks(List 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 upsertTask(Task task) async { await into(cachedTasks).insertOnConflictUpdate(_taskToCompanion(task)); } Future deleteTask(int id) async { await (delete(cachedTasks)..where((t) => t.id.equals(id))).go(); } // ── Projects ───────────────────────────────────────────────────────────── Future> getAllProjects() async { final rows = await (select(cachedProjects) ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) .get(); return rows.map(_projectFromRow).toList(); } Future getProject(int id) async { final row = await (select(cachedProjects)..where((t) => t.id.equals(id))) .getSingleOrNull(); return row == null ? null : _projectFromRow(row); } Future replaceAllProjects(List 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 upsertProject(Project project) async { await into(cachedProjects) .insertOnConflictUpdate(_projectToCompanion(project)); } Future deleteProject(int id) async { await (delete(cachedProjects)..where((t) => t.id.equals(id))).go(); } // ── Milestones ─────────────────────────────────────────────────────────── Future> 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 replaceMilestonesForProject( int projectId, List 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 upsertMilestone(Milestone milestone) async { await into(cachedMilestones) .insertOnConflictUpdate(_milestoneToCompanion(milestone)); } Future 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> 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 replaceEventsInRange( DateTime from, DateTime to, List 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 upsertEvent(CalendarEvent event) async { await into(cachedCalendarEvents) .insertOnConflictUpdate(_eventToCompanion(event)); } Future deleteEvent(int id) async { await (delete(cachedCalendarEvents)..where((t) => t.id.equals(id))).go(); } // ── Conversations (chat list — not messages) ───────────────────────────── Future> getAllConversations() async { final rows = await (select(cachedConversations) ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) .get(); return rows.map(_conversationFromRow).toList(); } Future replaceAllConversations( List 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 deleteConversation(int id) async { await (delete(cachedConversations)..where((t) => t.id.equals(id))).go(); } // ── Sync metadata ──────────────────────────────────────────────────────── Future 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 getLatestSync() async { final row = await (select(syncMetadata) ..orderBy([(t) => OrderingTerm.desc(t.lastSyncedAt)]) ..limit(1)) .getSingleOrNull(); return row?.lastSyncedAt; } Future _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 nextTempId() async { final query = customSelect( 'SELECT MIN(temp_id) AS m FROM pending_writes WHERE temp_id IS NOT NULL', ); final row = await query.getSingleOrNull(); final current = row?.read('m'); return (current ?? 0) - 1; } Future enqueuePending({ required String domain, required String verb, int? targetId, int? tempId, Map payload = const {}, DateTime? baselineUpdatedAt, }) { return into(pendingWrites).insert( PendingWritesCompanion.insert( domain: domain, verb: verb, targetId: Value(targetId), tempId: Value(tempId), payloadJson: Value(jsonEncode(payload)), baselineUpdatedAt: Value(baselineUpdatedAt), ), ); } /// Find the queued create-op for an offline-created row so a follow-up /// edit can be coalesced into the original payload (no separate update /// op gets queued). Returns null if no matching create is queued. Future findQueuedCreate(String domain, int tempId) { return (select(pendingWrites) ..where((t) => t.domain.equals(domain) & t.verb.equals(kWriteVerbCreate) & t.tempId.equals(tempId))) .getSingleOrNull(); } Future updatePendingPayload(int opId, Map payload) { return (update(pendingWrites)..where((t) => t.id.equals(opId))).write( PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))), ); } Future deletePending(int opId) { return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go(); } /// Pending ops oldest first — replay order. Future> listPending() { return (select(pendingWrites) ..orderBy([(t) => OrderingTerm.asc(t.id)])) .get(); } Future queueDepth() async { final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes') .getSingle(); return row.read('c'); } Future markPendingFailed(int opId, String error) { return (update(pendingWrites)..where((t) => t.id.equals(opId))).write( PendingWritesCompanion( tries: const Value.absent(), lastError: Value(error), ), ); } Future incrementPendingTries(int opId) { return customStatement( 'UPDATE pending_writes SET tries = tries + 1 WHERE id = ?', [opId], ); } /// Watch queue depth — used by OfflineBanner for the "Retry (N)" affordance. Stream watchQueueDepth() { final query = customSelect( 'SELECT COUNT(*) AS c FROM pending_writes', readsFrom: {pendingWrites}, ); return query.watchSingle().map((row) => row.read('c')); } } // ── Row ↔ model converters ────────────────────────────────────────────────── 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).cast(), 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); }); }