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/note.dart'; part 'database.g.dart'; /// Local cache of server-side Notes for offline reads. /// /// Mirrors the shape of [Note] one-to-one. Tags are stored as a JSON-encoded /// string because SQLite has no native list type and Drift's typed converters /// add ceremony we don't need for a simple read cache. `cachedAt` tracks when /// the row was last written from a successful API response. 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}; } /// Per-domain sync timestamp. Keyed by a short domain string ('notes', /// 'tasks', 'projects', 'events', etc.) so the OfflineBanner and any /// stale-while-revalidate logic can ask "when did we last fetch this". class SyncMetadata extends Table { TextColumn get domain => text()(); DateTimeColumn get lastSyncedAt => dateTime()(); @override Set get primaryKey => {domain}; } const String kSyncDomainNotes = 'notes'; @DriftDatabase(tables: [CachedNotes, SyncMetadata]) class FabledDatabase extends _$FabledDatabase { FabledDatabase() : super(_openConnection()); FabledDatabase.forTesting(super.executor); @override int get schemaVersion => 1; // ── 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); } /// Replace the entire notes cache with the latest server snapshot. /// Used after a successful `getAll()` so the cache reflects deletions /// (a row that's gone from the server should be gone locally too). Future replaceAllNotes(List notes) async { await transaction(() async { await delete(cachedNotes).go(); if (notes.isNotEmpty) { await batch((b) { b.insertAll( cachedNotes, notes.map(_companionFromNote).toList(), ); }); } await _setLastSyncIn(kSyncDomainNotes, DateTime.now()); }); } /// Upsert a single note (after a successful create / update / getOne). Future upsertNote(Note note) async { await into(cachedNotes).insertOnConflictUpdate(_companionFromNote(note)); } Future deleteNote(int id) async { await (delete(cachedNotes)..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; } Future _setLastSyncIn(String domain, DateTime timestamp) { return into(syncMetadata).insertOnConflictUpdate( SyncMetadataCompanion( domain: Value(domain), lastSyncedAt: Value(timestamp), ), ); } } CachedNotesCompanion _companionFromNote(Note note) => CachedNotesCompanion( id: Value(note.id), title: Value(note.title), body: Value(note.body), tagsJson: Value(jsonEncode(note.tags)), noteType: Value(note.noteType), projectId: Value(note.projectId), milestoneId: Value(note.milestoneId), createdAt: Value(note.createdAt), updatedAt: Value(note.updatedAt), ); Note _noteFromRow(CachedNote row) => Note( id: row.id, title: row.title, body: row.body, tags: (jsonDecode(row.tagsJson) as List).cast(), noteType: row.noteType, projectId: row.projectId, milestoneId: row.milestoneId, createdAt: row.createdAt, updatedAt: row.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); }); }