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}; } const String kSyncDomainNotes = 'notes'; const String kSyncDomainTasks = 'tasks'; const String kSyncDomainProjects = 'projects'; const String kSyncDomainMilestones = 'milestones'; const String kSyncDomainEvents = 'events'; const String kSyncDomainConversations = 'conversations'; @DriftDatabase(tables: [ CachedNotes, CachedTasks, CachedProjects, CachedMilestones, CachedCalendarEvents, CachedConversations, SyncMetadata, ]) class FabledDatabase extends _$FabledDatabase { FabledDatabase() : super(_openConnection()); FabledDatabase.forTesting(super.executor); @override int get schemaVersion => 2; @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); } }, ); // ── 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), ), ); } } // ── 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); }); }