diff --git a/lib/data/local/database.dart b/lib/data/local/database.dart new file mode 100644 index 0000000..baf5030 --- /dev/null +++ b/lib/data/local/database.dart @@ -0,0 +1,152 @@ +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); + }); +} diff --git a/lib/data/local/database.g.dart b/lib/data/local/database.g.dart new file mode 100644 index 0000000..8c99cff --- /dev/null +++ b/lib/data/local/database.g.dart @@ -0,0 +1,1282 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $CachedNotesTable extends CachedNotes + with TableInfo<$CachedNotesTable, CachedNote> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CachedNotesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bodyMeta = const VerificationMeta('body'); + @override + late final GeneratedColumn body = GeneratedColumn( + 'body', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _tagsJsonMeta = const VerificationMeta( + 'tagsJson', + ); + @override + late final GeneratedColumn tagsJson = GeneratedColumn( + 'tags_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('[]'), + ); + static const VerificationMeta _noteTypeMeta = const VerificationMeta( + 'noteType', + ); + @override + late final GeneratedColumn noteType = GeneratedColumn( + 'note_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('note'), + ); + static const VerificationMeta _projectIdMeta = const VerificationMeta( + 'projectId', + ); + @override + late final GeneratedColumn projectId = GeneratedColumn( + 'project_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _milestoneIdMeta = const VerificationMeta( + 'milestoneId', + ); + @override + late final GeneratedColumn milestoneId = GeneratedColumn( + 'milestone_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + clientDefault: () => DateTime.now(), + ); + @override + List get $columns => [ + id, + title, + body, + tagsJson, + noteType, + projectId, + milestoneId, + createdAt, + updatedAt, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cached_notes'; + @override + VerificationContext validateIntegrity( + Insertable 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('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('body')) { + context.handle( + _bodyMeta, + body.isAcceptableOrUnknown(data['body']!, _bodyMeta), + ); + } else if (isInserting) { + context.missing(_bodyMeta); + } + if (data.containsKey('tags_json')) { + context.handle( + _tagsJsonMeta, + tagsJson.isAcceptableOrUnknown(data['tags_json']!, _tagsJsonMeta), + ); + } + if (data.containsKey('note_type')) { + context.handle( + _noteTypeMeta, + noteType.isAcceptableOrUnknown(data['note_type']!, _noteTypeMeta), + ); + } + if (data.containsKey('project_id')) { + context.handle( + _projectIdMeta, + projectId.isAcceptableOrUnknown(data['project_id']!, _projectIdMeta), + ); + } + if (data.containsKey('milestone_id')) { + context.handle( + _milestoneIdMeta, + milestoneId.isAcceptableOrUnknown( + data['milestone_id']!, + _milestoneIdMeta, + ), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + CachedNote map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CachedNote( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + title: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + body: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}body'], + )!, + tagsJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags_json'], + )!, + noteType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note_type'], + )!, + projectId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}project_id'], + ), + milestoneId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}milestone_id'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $CachedNotesTable createAlias(String alias) { + return $CachedNotesTable(attachedDatabase, alias); + } +} + +class CachedNote extends DataClass implements Insertable { + final int id; + final String title; + final String body; + final String tagsJson; + final String noteType; + final int? projectId; + final int? milestoneId; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime cachedAt; + const CachedNote({ + required this.id, + required this.title, + required this.body, + required this.tagsJson, + required this.noteType, + this.projectId, + this.milestoneId, + required this.createdAt, + required this.updatedAt, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['title'] = Variable(title); + map['body'] = Variable(body); + map['tags_json'] = Variable(tagsJson); + map['note_type'] = Variable(noteType); + if (!nullToAbsent || projectId != null) { + map['project_id'] = Variable(projectId); + } + if (!nullToAbsent || milestoneId != null) { + map['milestone_id'] = Variable(milestoneId); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['cached_at'] = Variable(cachedAt); + return map; + } + + CachedNotesCompanion toCompanion(bool nullToAbsent) { + return CachedNotesCompanion( + id: Value(id), + title: Value(title), + body: Value(body), + tagsJson: Value(tagsJson), + noteType: Value(noteType), + projectId: projectId == null && nullToAbsent + ? const Value.absent() + : Value(projectId), + milestoneId: milestoneId == null && nullToAbsent + ? const Value.absent() + : Value(milestoneId), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + cachedAt: Value(cachedAt), + ); + } + + factory CachedNote.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CachedNote( + id: serializer.fromJson(json['id']), + title: serializer.fromJson(json['title']), + body: serializer.fromJson(json['body']), + tagsJson: serializer.fromJson(json['tagsJson']), + noteType: serializer.fromJson(json['noteType']), + projectId: serializer.fromJson(json['projectId']), + milestoneId: serializer.fromJson(json['milestoneId']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'title': serializer.toJson(title), + 'body': serializer.toJson(body), + 'tagsJson': serializer.toJson(tagsJson), + 'noteType': serializer.toJson(noteType), + 'projectId': serializer.toJson(projectId), + 'milestoneId': serializer.toJson(milestoneId), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + CachedNote copyWith({ + int? id, + String? title, + String? body, + String? tagsJson, + String? noteType, + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + DateTime? cachedAt, + }) => CachedNote( + id: id ?? this.id, + title: title ?? this.title, + body: body ?? this.body, + tagsJson: tagsJson ?? this.tagsJson, + noteType: noteType ?? this.noteType, + projectId: projectId.present ? projectId.value : this.projectId, + milestoneId: milestoneId.present ? milestoneId.value : this.milestoneId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + CachedNote copyWithCompanion(CachedNotesCompanion data) { + return CachedNote( + id: data.id.present ? data.id.value : this.id, + title: data.title.present ? data.title.value : this.title, + body: data.body.present ? data.body.value : this.body, + tagsJson: data.tagsJson.present ? data.tagsJson.value : this.tagsJson, + noteType: data.noteType.present ? data.noteType.value : this.noteType, + projectId: data.projectId.present ? data.projectId.value : this.projectId, + milestoneId: data.milestoneId.present + ? data.milestoneId.value + : this.milestoneId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('CachedNote(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('tagsJson: $tagsJson, ') + ..write('noteType: $noteType, ') + ..write('projectId: $projectId, ') + ..write('milestoneId: $milestoneId, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + title, + body, + tagsJson, + noteType, + projectId, + milestoneId, + createdAt, + updatedAt, + cachedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CachedNote && + other.id == this.id && + other.title == this.title && + other.body == this.body && + other.tagsJson == this.tagsJson && + other.noteType == this.noteType && + other.projectId == this.projectId && + other.milestoneId == this.milestoneId && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.cachedAt == this.cachedAt); +} + +class CachedNotesCompanion extends UpdateCompanion { + final Value id; + final Value title; + final Value body; + final Value tagsJson; + final Value noteType; + final Value projectId; + final Value milestoneId; + final Value createdAt; + final Value updatedAt; + final Value cachedAt; + const CachedNotesCompanion({ + this.id = const Value.absent(), + this.title = const Value.absent(), + this.body = const Value.absent(), + this.tagsJson = const Value.absent(), + this.noteType = const Value.absent(), + this.projectId = const Value.absent(), + this.milestoneId = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.cachedAt = const Value.absent(), + }); + CachedNotesCompanion.insert({ + this.id = const Value.absent(), + required String title, + required String body, + this.tagsJson = const Value.absent(), + this.noteType = const Value.absent(), + this.projectId = const Value.absent(), + this.milestoneId = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.cachedAt = const Value.absent(), + }) : title = Value(title), + body = Value(body), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? title, + Expression? body, + Expression? tagsJson, + Expression? noteType, + Expression? projectId, + Expression? milestoneId, + Expression? createdAt, + Expression? updatedAt, + Expression? cachedAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (title != null) 'title': title, + if (body != null) 'body': body, + if (tagsJson != null) 'tags_json': tagsJson, + if (noteType != null) 'note_type': noteType, + if (projectId != null) 'project_id': projectId, + if (milestoneId != null) 'milestone_id': milestoneId, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (cachedAt != null) 'cached_at': cachedAt, + }); + } + + CachedNotesCompanion copyWith({ + Value? id, + Value? title, + Value? body, + Value? tagsJson, + Value? noteType, + Value? projectId, + Value? milestoneId, + Value? createdAt, + Value? updatedAt, + Value? cachedAt, + }) { + return CachedNotesCompanion( + id: id ?? this.id, + title: title ?? this.title, + body: body ?? this.body, + tagsJson: tagsJson ?? this.tagsJson, + noteType: noteType ?? this.noteType, + projectId: projectId ?? this.projectId, + milestoneId: milestoneId ?? this.milestoneId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + cachedAt: cachedAt ?? this.cachedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (body.present) { + map['body'] = Variable(body.value); + } + if (tagsJson.present) { + map['tags_json'] = Variable(tagsJson.value); + } + if (noteType.present) { + map['note_type'] = Variable(noteType.value); + } + if (projectId.present) { + map['project_id'] = Variable(projectId.value); + } + if (milestoneId.present) { + map['milestone_id'] = Variable(milestoneId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CachedNotesCompanion(') + ..write('id: $id, ') + ..write('title: $title, ') + ..write('body: $body, ') + ..write('tagsJson: $tagsJson, ') + ..write('noteType: $noteType, ') + ..write('projectId: $projectId, ') + ..write('milestoneId: $milestoneId, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } +} + +class $SyncMetadataTable extends SyncMetadata + with TableInfo<$SyncMetadataTable, SyncMetadataData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SyncMetadataTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _domainMeta = const VerificationMeta('domain'); + @override + late final GeneratedColumn domain = GeneratedColumn( + 'domain', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastSyncedAtMeta = const VerificationMeta( + 'lastSyncedAt', + ); + @override + late final GeneratedColumn lastSyncedAt = GeneratedColumn( + 'last_synced_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [domain, lastSyncedAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_metadata'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('domain')) { + context.handle( + _domainMeta, + domain.isAcceptableOrUnknown(data['domain']!, _domainMeta), + ); + } else if (isInserting) { + context.missing(_domainMeta); + } + if (data.containsKey('last_synced_at')) { + context.handle( + _lastSyncedAtMeta, + lastSyncedAt.isAcceptableOrUnknown( + data['last_synced_at']!, + _lastSyncedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_lastSyncedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {domain}; + @override + SyncMetadataData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SyncMetadataData( + domain: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}domain'], + )!, + lastSyncedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_synced_at'], + )!, + ); + } + + @override + $SyncMetadataTable createAlias(String alias) { + return $SyncMetadataTable(attachedDatabase, alias); + } +} + +class SyncMetadataData extends DataClass + implements Insertable { + final String domain; + final DateTime lastSyncedAt; + const SyncMetadataData({required this.domain, required this.lastSyncedAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['domain'] = Variable(domain); + map['last_synced_at'] = Variable(lastSyncedAt); + return map; + } + + SyncMetadataCompanion toCompanion(bool nullToAbsent) { + return SyncMetadataCompanion( + domain: Value(domain), + lastSyncedAt: Value(lastSyncedAt), + ); + } + + factory SyncMetadataData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SyncMetadataData( + domain: serializer.fromJson(json['domain']), + lastSyncedAt: serializer.fromJson(json['lastSyncedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'domain': serializer.toJson(domain), + 'lastSyncedAt': serializer.toJson(lastSyncedAt), + }; + } + + SyncMetadataData copyWith({String? domain, DateTime? lastSyncedAt}) => + SyncMetadataData( + domain: domain ?? this.domain, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + ); + SyncMetadataData copyWithCompanion(SyncMetadataCompanion data) { + return SyncMetadataData( + domain: data.domain.present ? data.domain.value : this.domain, + lastSyncedAt: data.lastSyncedAt.present + ? data.lastSyncedAt.value + : this.lastSyncedAt, + ); + } + + @override + String toString() { + return (StringBuffer('SyncMetadataData(') + ..write('domain: $domain, ') + ..write('lastSyncedAt: $lastSyncedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(domain, lastSyncedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SyncMetadataData && + other.domain == this.domain && + other.lastSyncedAt == this.lastSyncedAt); +} + +class SyncMetadataCompanion extends UpdateCompanion { + final Value domain; + final Value lastSyncedAt; + final Value rowid; + const SyncMetadataCompanion({ + this.domain = const Value.absent(), + this.lastSyncedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + SyncMetadataCompanion.insert({ + required String domain, + required DateTime lastSyncedAt, + this.rowid = const Value.absent(), + }) : domain = Value(domain), + lastSyncedAt = Value(lastSyncedAt); + static Insertable custom({ + Expression? domain, + Expression? lastSyncedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (domain != null) 'domain': domain, + if (lastSyncedAt != null) 'last_synced_at': lastSyncedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + SyncMetadataCompanion copyWith({ + Value? domain, + Value? lastSyncedAt, + Value? rowid, + }) { + return SyncMetadataCompanion( + domain: domain ?? this.domain, + lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (domain.present) { + map['domain'] = Variable(domain.value); + } + if (lastSyncedAt.present) { + map['last_synced_at'] = Variable(lastSyncedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SyncMetadataCompanion(') + ..write('domain: $domain, ') + ..write('lastSyncedAt: $lastSyncedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$FabledDatabase extends GeneratedDatabase { + _$FabledDatabase(QueryExecutor e) : super(e); + $FabledDatabaseManager get managers => $FabledDatabaseManager(this); + late final $CachedNotesTable cachedNotes = $CachedNotesTable(this); + late final $SyncMetadataTable syncMetadata = $SyncMetadataTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + cachedNotes, + syncMetadata, + ]; +} + +typedef $$CachedNotesTableCreateCompanionBuilder = + CachedNotesCompanion Function({ + Value id, + required String title, + required String body, + Value tagsJson, + Value noteType, + Value projectId, + Value milestoneId, + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt, + }); +typedef $$CachedNotesTableUpdateCompanionBuilder = + CachedNotesCompanion Function({ + Value id, + Value title, + Value body, + Value tagsJson, + Value noteType, + Value projectId, + Value milestoneId, + Value createdAt, + Value updatedAt, + Value cachedAt, + }); + +class $$CachedNotesTableFilterComposer + extends Composer<_$FabledDatabase, $CachedNotesTable> { + $$CachedNotesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get noteType => $composableBuilder( + column: $table.noteType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CachedNotesTableOrderingComposer + extends Composer<_$FabledDatabase, $CachedNotesTable> { + $$CachedNotesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get noteType => $composableBuilder( + column: $table.noteType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get projectId => $composableBuilder( + column: $table.projectId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CachedNotesTableAnnotationComposer + extends Composer<_$FabledDatabase, $CachedNotesTable> { + $$CachedNotesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get body => + $composableBuilder(column: $table.body, builder: (column) => column); + + GeneratedColumn get tagsJson => + $composableBuilder(column: $table.tagsJson, builder: (column) => column); + + GeneratedColumn get noteType => + $composableBuilder(column: $table.noteType, builder: (column) => column); + + GeneratedColumn get projectId => + $composableBuilder(column: $table.projectId, builder: (column) => column); + + GeneratedColumn get milestoneId => $composableBuilder( + column: $table.milestoneId, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$CachedNotesTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $CachedNotesTable, + CachedNote, + $$CachedNotesTableFilterComposer, + $$CachedNotesTableOrderingComposer, + $$CachedNotesTableAnnotationComposer, + $$CachedNotesTableCreateCompanionBuilder, + $$CachedNotesTableUpdateCompanionBuilder, + ( + CachedNote, + BaseReferences<_$FabledDatabase, $CachedNotesTable, CachedNote>, + ), + CachedNote, + PrefetchHooks Function() + > { + $$CachedNotesTableTableManager(_$FabledDatabase db, $CachedNotesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CachedNotesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CachedNotesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CachedNotesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value title = const Value.absent(), + Value body = const Value.absent(), + Value tagsJson = const Value.absent(), + Value noteType = const Value.absent(), + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value cachedAt = const Value.absent(), + }) => CachedNotesCompanion( + id: id, + title: title, + body: body, + tagsJson: tagsJson, + noteType: noteType, + projectId: projectId, + milestoneId: milestoneId, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String title, + required String body, + Value tagsJson = const Value.absent(), + Value noteType = const Value.absent(), + Value projectId = const Value.absent(), + Value milestoneId = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value cachedAt = const Value.absent(), + }) => CachedNotesCompanion.insert( + id: id, + title: title, + body: body, + tagsJson: tagsJson, + noteType: noteType, + projectId: projectId, + milestoneId: milestoneId, + createdAt: createdAt, + updatedAt: updatedAt, + cachedAt: cachedAt, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CachedNotesTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $CachedNotesTable, + CachedNote, + $$CachedNotesTableFilterComposer, + $$CachedNotesTableOrderingComposer, + $$CachedNotesTableAnnotationComposer, + $$CachedNotesTableCreateCompanionBuilder, + $$CachedNotesTableUpdateCompanionBuilder, + ( + CachedNote, + BaseReferences<_$FabledDatabase, $CachedNotesTable, CachedNote>, + ), + CachedNote, + PrefetchHooks Function() + >; +typedef $$SyncMetadataTableCreateCompanionBuilder = + SyncMetadataCompanion Function({ + required String domain, + required DateTime lastSyncedAt, + Value rowid, + }); +typedef $$SyncMetadataTableUpdateCompanionBuilder = + SyncMetadataCompanion Function({ + Value domain, + Value lastSyncedAt, + Value rowid, + }); + +class $$SyncMetadataTableFilterComposer + extends Composer<_$FabledDatabase, $SyncMetadataTable> { + $$SyncMetadataTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SyncMetadataTableOrderingComposer + extends Composer<_$FabledDatabase, $SyncMetadataTable> { + $$SyncMetadataTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get domain => $composableBuilder( + column: $table.domain, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SyncMetadataTableAnnotationComposer + extends Composer<_$FabledDatabase, $SyncMetadataTable> { + $$SyncMetadataTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get domain => + $composableBuilder(column: $table.domain, builder: (column) => column); + + GeneratedColumn get lastSyncedAt => $composableBuilder( + column: $table.lastSyncedAt, + builder: (column) => column, + ); +} + +class $$SyncMetadataTableTableManager + extends + RootTableManager< + _$FabledDatabase, + $SyncMetadataTable, + SyncMetadataData, + $$SyncMetadataTableFilterComposer, + $$SyncMetadataTableOrderingComposer, + $$SyncMetadataTableAnnotationComposer, + $$SyncMetadataTableCreateCompanionBuilder, + $$SyncMetadataTableUpdateCompanionBuilder, + ( + SyncMetadataData, + BaseReferences< + _$FabledDatabase, + $SyncMetadataTable, + SyncMetadataData + >, + ), + SyncMetadataData, + PrefetchHooks Function() + > { + $$SyncMetadataTableTableManager(_$FabledDatabase db, $SyncMetadataTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SyncMetadataTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SyncMetadataTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SyncMetadataTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value domain = const Value.absent(), + Value lastSyncedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => SyncMetadataCompanion( + domain: domain, + lastSyncedAt: lastSyncedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String domain, + required DateTime lastSyncedAt, + Value rowid = const Value.absent(), + }) => SyncMetadataCompanion.insert( + domain: domain, + lastSyncedAt: lastSyncedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SyncMetadataTableProcessedTableManager = + ProcessedTableManager< + _$FabledDatabase, + $SyncMetadataTable, + SyncMetadataData, + $$SyncMetadataTableFilterComposer, + $$SyncMetadataTableOrderingComposer, + $$SyncMetadataTableAnnotationComposer, + $$SyncMetadataTableCreateCompanionBuilder, + $$SyncMetadataTableUpdateCompanionBuilder, + ( + SyncMetadataData, + BaseReferences<_$FabledDatabase, $SyncMetadataTable, SyncMetadataData>, + ), + SyncMetadataData, + PrefetchHooks Function() + >; + +class $FabledDatabaseManager { + final _$FabledDatabase _db; + $FabledDatabaseManager(this._db); + $$CachedNotesTableTableManager get cachedNotes => + $$CachedNotesTableTableManager(_db, _db.cachedNotes); + $$SyncMetadataTableTableManager get syncMetadata => + $$SyncMetadataTableTableManager(_db, _db.syncMetadata); +} diff --git a/lib/data/repositories/notes_repository.dart b/lib/data/repositories/notes_repository.dart index 681d4f8..4744748 100644 --- a/lib/data/repositories/notes_repository.dart +++ b/lib/data/repositories/notes_repository.dart @@ -1,12 +1,50 @@ +import '../../core/exceptions.dart'; import '../api/notes_api.dart'; +import '../local/database.dart'; import '../models/note.dart'; +/// Notes repository with read-through caching for Tier 2 offline support. +/// +/// Reads attempt the network first; on success the response is written to +/// 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 +/// fresh-install empty state). Writes (create / update / delete) hit the +/// server then sync the cache; offline write queueing is Phase 3 work and +/// not yet wired here — write methods will throw on `NetworkException` in +/// the meantime. +/// +/// `AuthStatus.offline` is owned by `AuthNotifier.verify()` (a periodic +/// heartbeat). This repository deliberately does not poke that state — its +/// only job is to serve what's cached when the network is unreachable. class NotesRepository { final NotesApi _api; - const NotesRepository(this._api); + final FabledDatabase _db; - Future> getAll() => _api.getAll(); - Future getOne(int id) => _api.getOne(id); + const NotesRepository(this._api, this._db); + + Future> getAll() async { + try { + final notes = await _api.getAll(); + await _db.replaceAllNotes(notes); + return notes; + } on NetworkException { + final cached = await _db.getAllNotes(); + if (cached.isEmpty) rethrow; + return cached; + } + } + + Future getOne(int id) async { + try { + final note = await _api.getOne(id); + await _db.upsertNote(note); + return note; + } on NetworkException { + final cached = await _db.getNote(id); + if (cached == null) rethrow; + return cached; + } + } Future create( String title, @@ -14,9 +52,17 @@ class NotesRepository { List tags = const [], int? projectId, String noteType = 'note', - }) => - _api.create(title, body, - tags: tags, projectId: projectId, noteType: noteType); + }) async { + final note = await _api.create( + title, + body, + tags: tags, + projectId: projectId, + noteType: noteType, + ); + await _db.upsertNote(note); + return note; + } Future update( int id, @@ -26,12 +72,27 @@ class NotesRepository { int? projectId, bool clearProject = false, String noteType = 'note', - }) => - _api.update(id, title, body, - tags: tags, - projectId: projectId, - clearProject: clearProject, - noteType: noteType); + }) async { + final updated = await _api.update( + id, + title, + body, + tags: tags, + projectId: projectId, + clearProject: clearProject, + noteType: noteType, + ); + await _db.upsertNote(updated); + return updated; + } - Future delete(int id) => _api.delete(id); + Future delete(int id) async { + await _api.delete(id); + await _db.deleteNote(id); + } + + /// Last successful `getAll` sync timestamp. Surfaced by the OfflineBanner + /// when the network is unreachable so the user knows how stale the + /// cached notes list is. + Future lastSyncedAt() => _db.getLastSync(kSyncDomainNotes); } diff --git a/lib/providers/api_client_provider.dart b/lib/providers/api_client_provider.dart index 5b0a50e..6b194b3 100644 --- a/lib/providers/api_client_provider.dart +++ b/lib/providers/api_client_provider.dart @@ -15,6 +15,7 @@ import '../data/api/projects_api.dart'; import '../data/api/quick_capture_api.dart'; import '../data/api/settings_api.dart'; import '../data/api/tasks_api.dart'; +import '../data/local/database.dart'; import '../data/repositories/auth_repository.dart'; import '../data/repositories/chat_repository.dart'; import '../data/repositories/knowledge_repository.dart'; @@ -60,12 +61,24 @@ final projectsApiProvider = Provider((ref) { return ProjectsApi(ref.watch(dioProvider)); }); +/// Local SQLite cache used by repositories for read-through caching and +/// offline fallback. Backed by Drift; lives for the lifetime of the +/// ProviderScope and is closed automatically when the scope disposes. +final fabledDatabaseProvider = Provider((ref) { + final db = FabledDatabase(); + ref.onDispose(db.close); + return db; +}); + final authRepositoryProvider = Provider((ref) { return AuthRepository(ref.watch(authApiProvider)); }); final notesRepositoryProvider = Provider((ref) { - return NotesRepository(ref.watch(notesApiProvider)); + return NotesRepository( + ref.watch(notesApiProvider), + ref.watch(fabledDatabaseProvider), + ); }); final tasksRepositoryProvider = Provider((ref) { diff --git a/lib/widgets/offline_banner.dart b/lib/widgets/offline_banner.dart index 94137e1..8cfa6f1 100644 --- a/lib/widgets/offline_banner.dart +++ b/lib/widgets/offline_banner.dart @@ -1,12 +1,15 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/api_client_provider.dart'; import '../providers/auth_provider.dart'; /// Sticky banner shown when the backend is unreachable. Surfaces a retry -/// action and reserves space for future "last sync X min ago" messaging once -/// Tier 2 offline caching lands. +/// action plus a "last sync X ago" hint when there is cached content to +/// fall back on (Tier 2 offline mode). class OfflineBanner extends ConsumerStatefulWidget { const OfflineBanner({super.key}); @@ -16,6 +19,38 @@ class OfflineBanner extends ConsumerStatefulWidget { class _OfflineBannerState extends ConsumerState { bool _retrying = false; + DateTime? _lastSync; + Timer? _refreshTimer; + + @override + void initState() { + super.initState(); + _loadLastSync(); + // Refresh the relative-time string every 30s so "5 min ago" rolls + // forward without the user having to interact with the banner. + _refreshTimer = Timer.periodic( + const Duration(seconds: 30), + (_) => _loadLastSync(), + ); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + + Future _loadLastSync() async { + if (!mounted) return; + try { + final ts = + await ref.read(notesRepositoryProvider).lastSyncedAt(); + if (!mounted) return; + setState(() => _lastSync = ts); + } catch (_) { + // Non-critical — banner just won't show the sync hint. + } + } Future _retry() async { if (_retrying) return; @@ -27,12 +62,30 @@ class _OfflineBannerState extends ConsumerState { } } + String? _relativeAgo(DateTime ts) { + final diff = DateTime.now().difference(ts); + if (diff.isNegative) return null; + if (diff.inMinutes < 1) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes} min ago'; + if (diff.inHours < 24) { + final h = diff.inHours; + return '$h ${h == 1 ? 'hour' : 'hours'} ago'; + } + final d = diff.inDays; + return '$d ${d == 1 ? 'day' : 'days'} ago'; + } + @override Widget build(BuildContext context) { final status = ref.watch(authProvider); if (status != AuthStatus.offline) return const SizedBox.shrink(); final scheme = Theme.of(context).colorScheme; + final ago = _lastSync == null ? null : _relativeAgo(_lastSync!); + final message = ago == null + ? 'Offline — showing cached data.' + : 'Offline — last sync $ago.'; + return Material( color: scheme.errorContainer, child: SafeArea( @@ -46,7 +99,7 @@ class _OfflineBannerState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - 'Offline — showing cached data.', + message, style: TextStyle(color: scheme.onErrorContainer), ), ), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f06a07c..1b65d65 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) record_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); record_linux_plugin_register_with_registrar(record_linux_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 61405b3..81e44a2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_timezone open_file_linux record_linux + sqlite3_flutter_libs url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 74ec380..8885969 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import open_file_mac import package_info_plus import record_macos import shared_preferences_foundation +import sqlite3_flutter_libs import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -24,5 +25,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index e7d0b33..c24a653 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + url: "https://pub.dev" + source: hosted + version: "4.0.6" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "22fdcc3cfeb9d974d7408718c4be15ec5e9b1b350088f3a6c88f154e74dd700d" + url: "https://pub.dev" + source: hosted + version: "2.14.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + url: "https://pub.dev" + source: hosted + version: "8.12.5" characters: dependency: transitive description: @@ -65,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -153,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + url: "https://pub.dev" + source: hosted + version: "3.1.7" dio: dependency: "direct main" description: @@ -177,6 +241,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + drift: + dependency: "direct main" + description: + name: drift + sha256: "055c249d1f91be5a47fe447f88afc24c4ca6f4cd6c5ed66767b4797d48acc2e5" + url: "https://pub.dev" + source: hosted + version: "2.32.1" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "88a9de3af8571518148a6d8a513b57779fd1e60a026d3ab8a481a878fba01d91" + url: "https://pub.dev" + source: hosted + version: "2.32.1" equatable: dependency: transitive description: @@ -368,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.0.2" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" hooks: dependency: transitive description: @@ -657,7 +745,7 @@ packages: source: hosted version: "3.2.1" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -808,6 +896,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" record: dependency: "direct main" description: @@ -989,6 +1093,14 @@ packages: description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" source_map_stack_trace: dependency: transitive description: @@ -1013,6 +1125,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: ab2b467425f1d4f3acfa5fd11a08226f7d6c26ff102c06be1807e1dff34e050b + url: "https://pub.dev" + source: hosted + version: "0.44.3" stack_trace: dependency: transitive description: @@ -1037,6 +1173,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5a4fc9e..7698e33 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,11 +34,20 @@ dependencies: just_audio: ^0.9.39 table_calendar: ^3.1.2 + # Tier 2 offline mode — local SQL store via Drift (sqlite3 under the hood). + # Drift was preferred over Hive in the design since the repo already mirrors + # well-defined backend schemas (notes/tasks/projects/etc.) one-to-one. + drift: ^2.20.0 + sqlite3_flutter_libs: ^0.5.24 + path: ^1.9.0 + dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.3 + drift_dev: ^2.20.0 + build_runner: ^2.4.13 flutter_launcher_icons: android: true diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f1cac88..3d5668f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); + Sqlite3FlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index c57f7ba..86c9845 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_timezone permission_handler_windows record_windows + sqlite3_flutter_libs url_launcher_windows )