diff --git a/.metadata b/.metadata index 3a5a3af..c84df4c 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020" + revision: "ee80f08bbf97172ec030b8751ceab557177a34a6" channel: "stable" project_type: app @@ -13,14 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - - platform: android - create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - - platform: linux - create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 - base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020 + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + - platform: web + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 # User provided section diff --git a/lib/data/desktop_backup_client.dart b/lib/data/desktop_backup_client.dart new file mode 100644 index 0000000..7563677 --- /dev/null +++ b/lib/data/desktop_backup_client.dart @@ -0,0 +1,57 @@ +import 'package:http/http.dart' as http; + +/// Talks to the local desktop wrapper that serves the web build. +/// +/// The wrapper exists because a browser cannot write to the filesystem, and two +/// things depend on it: +/// +/// * `~/todo/BACKLOG.md` — read by the user's tooling and the `todo` MCP +/// server. Without this it would silently go stale after the move to a web +/// build. +/// * an on-disk copy of the note log, so a wiped Chrome profile is not a +/// total-loss event for notes that have not reached GitHub yet. +/// +/// Every method is best-effort and swallows failures: the app must remain fully +/// usable when the wrapper is absent, e.g. when the build is opened in a plain +/// browser tab or on mobile. +class DesktopBackupClient { + /// Creates a client targeting [baseUrl] (the wrapper's origin). + DesktopBackupClient({required this.baseUrl, http.Client? httpClient}) + : _client = httpClient ?? http.Client(); + + /// Origin the wrapper listens on, e.g. `http://localhost:8730`. + final String baseUrl; + + final http.Client _client; + + /// Writes the Markdown backlog export to disk via the wrapper. + Future writeBacklog(String markdown) => + _post('/backup/backlog', markdown); + + /// Writes the serialised note log to disk via the wrapper. + Future writeLog(String json) => _post('/backup/log', json); + + /// Reads the wrapper's on-disk copy of the log, or null if unavailable. + Future readLog() => _get('/backup/log'); + + /// Reads the on-disk Markdown backlog, or null if unavailable. + Future readBacklog() => _get('/backup/backlog'); + + Future _get(String path) async { + try { + final response = await _client.get(Uri.parse('$baseUrl$path')); + if (response.statusCode != 200 || response.body.isEmpty) return null; + return response.body; + } on Exception { + return null; + } + } + + Future _post(String path, String body) async { + try { + await _client.post(Uri.parse('$baseUrl$path'), body: body); + } on Exception { + // Best-effort by design; see the class docs. + } + } +} diff --git a/lib/data/legacy_sqlite_migration.dart b/lib/data/legacy_sqlite_migration.dart new file mode 100644 index 0000000..843db27 --- /dev/null +++ b/lib/data/legacy_sqlite_migration.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync.dart'; +// Only SqliteCrdt/CrdtTableExecutor are needed to read the legacy DB; hide its +// Hlc so the crdt_sync Hlc (the store's clock type) is unambiguous. +import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc; +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; + +/// Reads a pre-crdt_sync `sqlite_crdt` database into a crdt_sync [Log]. +/// +/// Lives in its own `dart:io` library so [NoteRepository] can stay pure Dart: +/// the desktop app is a web build, and one `dart:io` import anywhere in the +/// graph fails the whole web compile. Mobile still runs this on first launch +/// after the cutover; web never does, because there is no legacy DB in a +/// browser profile. +class LegacySqliteMigration { + /// Whether a legacy database exists at [path] to migrate from. + static bool exists(String path) => File(path).existsSync(); + + /// Opens the legacy DB at [path] and returns its notes as a [Log], plus the + /// legacy CRDT node id. + /// + /// Reusing the legacy node id keeps a device that already synced on the same + /// `devices/` identity across the cutover, so it does not appear as a new + /// peer and re-upload everything. + static Future<({Log log, String nodeId})> read( + String path, + String preferredNodeId, + ) async { + final legacy = await SqliteCrdt.open( + path, + version: _version, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + ); + final nodeId = preferredNodeId.isEmpty ? legacy.nodeId : preferredNodeId; + final rows = await legacy.query( + 'SELECT id, text, priority, status, created_at, updated_at, is_deleted ' + 'FROM notes', + ); + final log = {}; + for (final row in rows) { + final note = Note.fromRow(row); + log[note.id] = NoteRepository.legacyRecordFor( + note, + nodeId, + deleted: (row['is_deleted'] as int? ?? 0) != 0, + ); + } + await legacy.close(); + return (log: log, nodeId: nodeId); + } + + static const int _version = 3; + + // coverage:ignore-start + // Unreachable: migration only ever opens a legacy DB that already exists + // (guarded by [exists]), so sqlite never runs onCreate. Kept because + // SqliteCrdt.open requires the callback. + static Future _onCreate(CrdtTableExecutor db, int version) async { + await db.execute(''' + CREATE TABLE notes ( + id TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 2, + status INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (id) + ) + '''); + } + // coverage:ignore-end + + static Future _onUpgrade( + CrdtTableExecutor db, + int from, + int to, + ) async { + if (from < 2) { + await db.execute( + 'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0', + ); + } + if (from < 3) { + await db.execute('UPDATE notes SET priority = 2 WHERE priority = 0'); + } + } +} diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index 8cb2b83..7da1805 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -1,15 +1,13 @@ import 'dart:async'; -import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; -import 'package:crdt_sync/crdt_sync_io.dart'; -import 'package:path/path.dart' as p; -import 'package:shared_preferences/shared_preferences.dart'; -// Only SqliteCrdt/CrdtTableExecutor are needed for the legacy-DB migration; -// hide its Hlc so the crdt_sync Hlc (the store's clock type) is unambiguous. -import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc; import 'package:todo/data/note.dart'; -import 'package:uuid/uuid.dart'; + +// Deliberately free of `dart:io`: the desktop app is a web build, and a single +// `dart:io` import anywhere in the graph makes the whole app fail to compile +// for web. Opening a repository for a real platform lives behind the +// conditional export in `repository_factory.dart`; the legacy sqlite migration +// lives in the io-only `legacy_sqlite_migration.dart`. /// How the history list should be ordered. enum NoteSort { @@ -77,52 +75,37 @@ class NoteRepository { static const _fCreatedAt = 'created_at'; static const _fUpdatedAt = 'updated_at'; - static const _kNodeId = 'crdt.nodeId'; - static const _kMigrated = 'crdt.migratedFromSqlite'; - static const _logFileName = 'todo_notes.json'; + /// SharedPreferences key holding this device's stable CRDT node id. + static const kNodeId = 'crdt.nodeId'; - /// Opens (or creates) the note log next to the legacy DB at [path]. + /// SharedPreferences key recording that the legacy sqlite import already ran. + static const kMigrated = 'crdt.migratedFromSqlite'; + + /// File name of the persisted log, used by the io factory. + static const logFileName = 'todo_notes.json'; + + /// Opens (or creates) the note log backed by [persistence]. /// - /// On first launch after the crdt_sync migration this performs a one-time, - /// idempotent import of the existing `sqlite_crdt` database at [path] into - /// the new log (guarded by a persisted "migrated" flag), so no backlog is - /// lost. The legacy DB file is left untouched as a safety net. - static Future open(String path) async { - final prefs = await SharedPreferences.getInstance(); - final logFile = File(p.join(p.dirname(path), _logFileName)); - final persistence = FileLogPersistence(logFile); - - final migrated = prefs.getBool(_kMigrated) ?? false; - var nodeId = prefs.getString(_kNodeId) ?? ''; - - if (!migrated && File(path).existsSync()) { - final legacy = await SqliteCrdt.open( - path, - version: _legacyVersion, - onCreate: _legacyOnCreate, - onUpgrade: _legacyOnUpgrade, - ); - // Reuse the legacy CRDT node id so a device that already synced keeps - // the same devices/ identity across the cutover. - if (nodeId.isEmpty) nodeId = legacy.nodeId; - final store = LogStore(persistence: persistence, nodeId: nodeId); - await store.load(); - await _seedFromLegacy(legacy, store, nodeId); - await legacy.close(); - await prefs.setString(_kNodeId, nodeId); - await prefs.setBool(_kMigrated, true); - return NoteRepository._(store, nodeId); - } - - if (nodeId.isEmpty) { - nodeId = const Uuid().v4(); - await prefs.setString(_kNodeId, nodeId); - } + /// Platform wiring — where the log lives, and the one-time legacy sqlite + /// import — belongs to the callers in `repository_factory_*.dart`, so that + /// this class stays free of `dart:io` and can compile for web. + static Future openWith({ + required LogPersistence persistence, + required String nodeId, + }) async { final store = LogStore(persistence: persistence, nodeId: nodeId); await store.load(); return NoteRepository._(store, nodeId); } + /// Replaces the whole log, used by the one-time legacy sqlite migration. + /// + /// Distinct from [importLog], which merges: migration seeds a store that is + /// known to be empty and must preserve the records' own clocks verbatim. + Future seedFrom(Log log) async { + if (log.isNotEmpty) await _store.replaceAll(log); + } + /// Opens a transient in-memory log; intended for tests. static Future openInMemory({ String nodeId = 'test-node', @@ -132,38 +115,29 @@ class NoteRepository { return NoteRepository._(store, nodeId); } - /// Copies every legacy note (tombstones included) into [store] in one write. + /// Builds the record for one migrated legacy note, tombstones included. /// /// Each note's field clocks are seeded from its own `updated_at`, not "now", /// so that when two devices migrate independently, a pre-cutover edit still /// wins by real edit time under per-field last-writer-wins instead of being - /// resolved arbitrarily. - static Future _seedFromLegacy( - SqliteCrdt legacy, - LogStore store, - String nodeId, - ) async { - final rows = await legacy.query( - 'SELECT id, text, priority, status, created_at, updated_at, is_deleted ' - 'FROM notes', + /// resolved arbitrarily. Public because the migration that calls it lives in + /// the io-only library, which cannot reach private members here. + static Record legacyRecordFor( + Note note, + String nodeId, { + bool deleted = false, + }) { + final hlc = Hlc( + wallTimeMs: note.updatedAt.millisecondsSinceEpoch, + counter: 0, + nodeId: nodeId, + ); + return Record( + id: note.id, + fields: _fieldsFor(note, hlc), + deleted: deleted, + deletedHlc: deleted ? hlc : null, ); - final log = {}; - for (final row in rows) { - final note = Note.fromRow(row); - final hlc = Hlc( - wallTimeMs: note.updatedAt.millisecondsSinceEpoch, - counter: 0, - nodeId: nodeId, - ); - final deleted = (row['is_deleted'] as int? ?? 0) != 0; - log[note.id] = Record( - id: note.id, - fields: _fieldsFor(note, hlc), - deleted: deleted, - deletedHlc: deleted ? hlc : null, - ); - } - if (log.isNotEmpty) await store.replaceAll(log); } /// Inserts a new note or updates the existing one with the same [id]. @@ -346,44 +320,6 @@ class NoteRepository { /// Midnight (local) of [t]'s calendar day. static DateTime _startOfDay(DateTime t) => DateTime(t.year, t.month, t.day); - - // --- legacy sqlite_crdt schema, used only to read the DB during migration - - - static const int _legacyVersion = 3; - - // coverage:ignore-start - // Unreachable: migration only ever opens a legacy DB that already exists - // (guarded by `File(path).existsSync()`), so sqlite never runs onCreate. - // Kept because SqliteCrdt.open requires the callback. - static Future _legacyOnCreate(CrdtTableExecutor db, int version) async { - await db.execute(''' - CREATE TABLE notes ( - id TEXT NOT NULL, - text TEXT NOT NULL DEFAULT '', - priority INTEGER NOT NULL DEFAULT 2, - status INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (id) - ) - '''); - } - // coverage:ignore-end - - static Future _legacyOnUpgrade( - CrdtTableExecutor db, - int from, - int to, - ) async { - if (from < 2) { - await db.execute( - 'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0', - ); - } - if (from < 3) { - await db.execute('UPDATE notes SET priority = 2 WHERE priority = 0'); - } - } } /// In-memory [LogPersistence] for [NoteRepository.openInMemory] (tests). diff --git a/lib/data/repository_factory.dart b/lib/data/repository_factory.dart new file mode 100644 index 0000000..8cb888e --- /dev/null +++ b/lib/data/repository_factory.dart @@ -0,0 +1,9 @@ +/// Platform entry point for opening the note store. +/// +/// Conditional export because `dart:io` cannot even be *imported* in a web +/// compile: mobile gets the file-backed store plus the legacy sqlite import, +/// web gets IndexedDB plus the desktop wrapper's disk copy. +library; + +export 'repository_factory_io.dart' + if (dart.library.js_interop) 'repository_factory_web.dart'; diff --git a/lib/data/repository_factory_io.dart b/lib/data/repository_factory_io.dart new file mode 100644 index 0000000..b870980 --- /dev/null +++ b/lib/data/repository_factory_io.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:crdt_sync/crdt_sync_io.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:todo/data/legacy_sqlite_migration.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:uuid/uuid.dart'; + +/// Opens the note store on a `dart:io` platform (Android). +/// +/// Also performs the one-time, idempotent import of a pre-crdt_sync sqlite +/// database, guarded by a persisted flag. The legacy DB file is left untouched +/// as a safety net. +// coverage:ignore-start +// Resolves the real per-platform application-support directory, so it cannot +// run under test; [openRepositoryIn] holds all the logic and is covered. +Future openRepository() async { + final dir = await getApplicationSupportDirectory(); + return openRepositoryIn(dir.path); +} +// coverage:ignore-end + +/// Opens the note store rooted at [dirPath]. +/// +/// Split out from [openRepository] so tests can drive the legacy-migration +/// paths against a temporary directory without going through `path_provider`. +Future openRepositoryIn(String dirPath) async { + // Keep the FFI initialisation for any non-Android io host that still reads a + // legacy DB (and for the test host, which is Linux). + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + sqfliteFfiInit(); + } + + final dbPath = p.join(dirPath, 'todo.db'); + final persistence = FileLogPersistence( + File(p.join(dirPath, NoteRepository.logFileName)), + ); + + final prefs = await SharedPreferences.getInstance(); + final migrated = prefs.getBool(NoteRepository.kMigrated) ?? false; + var nodeId = prefs.getString(NoteRepository.kNodeId) ?? ''; + + if (!migrated && LegacySqliteMigration.exists(dbPath)) { + final legacy = await LegacySqliteMigration.read(dbPath, nodeId); + nodeId = legacy.nodeId; + final repository = await NoteRepository.openWith( + persistence: persistence, + nodeId: nodeId, + ); + await repository.seedFrom(legacy.log); + await prefs.setString(NoteRepository.kNodeId, nodeId); + await prefs.setBool(NoteRepository.kMigrated, true); + return repository; + } + + if (nodeId.isEmpty) { + nodeId = const Uuid().v4(); + await prefs.setString(NoteRepository.kNodeId, nodeId); + } + return NoteRepository.openWith(persistence: persistence, nodeId: nodeId); +} diff --git a/lib/data/repository_factory_web.dart b/lib/data/repository_factory_web.dart new file mode 100644 index 0000000..5461e32 --- /dev/null +++ b/lib/data/repository_factory_web.dart @@ -0,0 +1,27 @@ +import 'package:idb_shim/idb_browser.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:todo/data/desktop_backup_client.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/web_log_persistence.dart'; +import 'package:todo/sync/desktop_wrapper.dart'; +import 'package:uuid/uuid.dart'; + +/// Opens the note store in a browser (the desktop app). +/// +/// There is no legacy sqlite database in a browser profile, so the migration +/// path is simply absent here rather than skipped at runtime. +Future openRepository() async { + final database = await WebLogPersistence.openDatabase(idbFactoryBrowser); + final persistence = WebLogPersistence( + database: database, + backup: DesktopBackupClient(baseUrl: desktopWrapperOrigin), + ); + + final prefs = await SharedPreferences.getInstance(); + var nodeId = prefs.getString(NoteRepository.kNodeId) ?? ''; + if (nodeId.isEmpty) { + nodeId = const Uuid().v4(); + await prefs.setString(NoteRepository.kNodeId, nodeId); + } + return NoteRepository.openWith(persistence: persistence, nodeId: nodeId); +} diff --git a/lib/data/web_log_persistence.dart b/lib/data/web_log_persistence.dart new file mode 100644 index 0000000..a4b2812 --- /dev/null +++ b/lib/data/web_log_persistence.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:idb_shim/idb.dart'; +import 'package:todo/data/desktop_backup_client.dart'; + +/// [LogPersistence] backed by IndexedDB, with a best-effort copy pushed to the +/// desktop wrapper so the notes also exist on disk. +/// +/// IndexedDB rather than `localStorage` because the latter caps at roughly +/// 5-10MB per origin and is evicted more eagerly; the note log is the primary +/// copy of the backlog and has to outlive storage pressure. +/// +/// The disk copy exists because moving the desktop app into a browser would +/// otherwise leave a wiped Chrome profile as a total-loss event for notes that +/// have not synced to GitHub yet. It is deliberately fire-and-forget: the +/// wrapper is an optimisation, and the app must stay fully usable when it is +/// absent (for instance when the same build is opened in a plain browser tab). +class WebLogPersistence implements LogPersistence { + /// Creates a persistence over [database], mirroring writes to [backup]. + WebLogPersistence({required Database database, this.backup}) + : _db = database; + + final Database _db; + + /// Wrapper client that mirrors writes to disk, or null when absent. + final DesktopBackupClient? backup; + + /// Object store holding a single record: the serialised log. + static const storeName = 'log'; + + /// Key of that single record. + static const recordKey = 'notes'; + + /// IndexedDB database name. + static const databaseName = 'todo'; + + /// Opens (creating if needed) the IndexedDB database backing the log. + static Future openDatabase(IdbFactory factory) => factory.open( + databaseName, + version: 1, + onUpgradeNeeded: (event) { + final db = event.database; + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName); + } + }, + ); + + @override + Future read() async { + final txn = _db.transaction(storeName, idbModeReadOnly); + final value = await txn.objectStore(storeName).getObject(recordKey); + await txn.completed; + if (value is String && value.isNotEmpty) return value; + // Nothing local yet (fresh profile, or the browser evicted the database). + // Fall back to the wrapper's on-disk copy so a cleared profile recovers + // instead of silently starting from an empty backlog. + return backup?.readLog(); + } + + @override + Future write(String text) async { + final txn = _db.transaction(storeName, idbModeReadWrite); + await txn.objectStore(storeName).put(text, recordKey); + await txn.completed; + unawaited(backup?.writeLog(text) ?? Future.value()); + } +} diff --git a/lib/frame_stats.dart b/lib/frame_stats.dart index b97f44a..de3e342 100644 --- a/lib/frame_stats.dart +++ b/lib/frame_stats.dart @@ -1,84 +1,10 @@ -// coverage:ignore-file -// TEMPORARY diagnostic instrument (desktop interaction-lag investigation). -// Guarded by TODO_FRAME_STATS=1 so it is inert in normal use. -// -// v3 adds an event-loop blocking monitor, which is the decisive instrument. -// Frame gaps are ambiguous in a mostly-static UI: no frame is produced when -// nothing needs drawing, so an idle user looks identical to a stalled isolate. -// A periodic timer, by contrast, is scheduled regardless of drawing -- if it -// fires late, the UI isolate was genuinely blocked, and by exactly how long. -import 'dart:async'; -import 'dart:io'; +/// Platform entry point for the temporary frame instrument. +/// +/// Conditional export because the io version writes to `stdout` and reads +/// `Platform.environment`, neither of which exists in a browser. The web +/// version reports to the devtools console instead and is armed at build time +/// with `--dart-define=TODO_FRAME_STATS=1`. +library; -import 'package:flutter/scheduler.dart'; - -/// Whether the frame-stats instrument is armed for this process. -bool get frameStatsEnabled => Platform.environment['TODO_FRAME_STATS'] == '1'; - -/// A frame whose own work exceeded this is a hitch worth naming. Set to zero -/// by TODO_FRAME_STATS_ALL=1 so every frame is logged, which is what the -/// raster-cost-vs-window-size sweep needs. -int get _slowFrameUs => - Platform.environment['TODO_FRAME_STATS_ALL'] == '1' ? -1 : 8000; - -/// How often the event-loop probe is scheduled. -const _probePeriod = Duration(milliseconds: 16); - -/// Lateness beyond this means the isolate was blocked long enough to be felt. -const _blockedMs = 24; - -final Stopwatch _clock = Stopwatch(); -Timer? _probe; - -String get _at => (_clock.elapsedMilliseconds / 1000).toStringAsFixed(2); - -/// Installs the frame-timing and event-loop instruments. -void installFrameStats() { - if (!frameStatsEnabled) return; - _clock.start(); - stdout.writeln( - '[frame-stats v3] armed; logging slow frames and UI-isolate blocking', - ); - _installFrameTimings(); - _installEventLoopProbe(); -} - -void _installFrameTimings() { - SchedulerBinding.instance.addTimingsCallback((timings) { - for (final t in timings) { - final build = t.buildDuration.inMicroseconds; - final raster = t.rasterDuration.inMicroseconds; - if (build + raster > _slowFrameUs) { - stdout.writeln( - '[FRAME $_at s] total=${((build + raster) / 1000) - .toStringAsFixed(1)}ms ' - '(build=${(build / 1000).toStringAsFixed(1)} ' - 'raster=${(raster / 1000).toStringAsFixed(1)})', - ); - } - } - }); -} - -/// Schedules a timer every [_probePeriod] and reports how late it actually -/// fired. Lateness is time the UI isolate spent unable to run anything -- e.g. -/// a synchronous JSON serialize of the whole note log. -void _installEventLoopProbe() { - var expected = _clock.elapsedMicroseconds + _probePeriod.inMicroseconds; - void tick(Timer _) { - final now = _clock.elapsedMicroseconds; - final lateMs = (now - expected) / 1000; - if (lateMs > _blockedMs) { - stdout.writeln( - '[UI-BLOCKED $_at s] event loop stalled ' - '${lateMs.toStringAsFixed(1)}ms', - ); - } - expected = now + _probePeriod.inMicroseconds; - } - - _probe = Timer.periodic(_probePeriod, tick); -} - -/// Stops the probe. Only needed if the instrument outlives the app. -void disposeFrameStats() => _probe?.cancel(); +export 'frame_stats_io.dart' + if (dart.library.js_interop) 'frame_stats_web.dart'; diff --git a/lib/frame_stats_io.dart b/lib/frame_stats_io.dart new file mode 100644 index 0000000..b97f44a --- /dev/null +++ b/lib/frame_stats_io.dart @@ -0,0 +1,84 @@ +// coverage:ignore-file +// TEMPORARY diagnostic instrument (desktop interaction-lag investigation). +// Guarded by TODO_FRAME_STATS=1 so it is inert in normal use. +// +// v3 adds an event-loop blocking monitor, which is the decisive instrument. +// Frame gaps are ambiguous in a mostly-static UI: no frame is produced when +// nothing needs drawing, so an idle user looks identical to a stalled isolate. +// A periodic timer, by contrast, is scheduled regardless of drawing -- if it +// fires late, the UI isolate was genuinely blocked, and by exactly how long. +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/scheduler.dart'; + +/// Whether the frame-stats instrument is armed for this process. +bool get frameStatsEnabled => Platform.environment['TODO_FRAME_STATS'] == '1'; + +/// A frame whose own work exceeded this is a hitch worth naming. Set to zero +/// by TODO_FRAME_STATS_ALL=1 so every frame is logged, which is what the +/// raster-cost-vs-window-size sweep needs. +int get _slowFrameUs => + Platform.environment['TODO_FRAME_STATS_ALL'] == '1' ? -1 : 8000; + +/// How often the event-loop probe is scheduled. +const _probePeriod = Duration(milliseconds: 16); + +/// Lateness beyond this means the isolate was blocked long enough to be felt. +const _blockedMs = 24; + +final Stopwatch _clock = Stopwatch(); +Timer? _probe; + +String get _at => (_clock.elapsedMilliseconds / 1000).toStringAsFixed(2); + +/// Installs the frame-timing and event-loop instruments. +void installFrameStats() { + if (!frameStatsEnabled) return; + _clock.start(); + stdout.writeln( + '[frame-stats v3] armed; logging slow frames and UI-isolate blocking', + ); + _installFrameTimings(); + _installEventLoopProbe(); +} + +void _installFrameTimings() { + SchedulerBinding.instance.addTimingsCallback((timings) { + for (final t in timings) { + final build = t.buildDuration.inMicroseconds; + final raster = t.rasterDuration.inMicroseconds; + if (build + raster > _slowFrameUs) { + stdout.writeln( + '[FRAME $_at s] total=${((build + raster) / 1000) + .toStringAsFixed(1)}ms ' + '(build=${(build / 1000).toStringAsFixed(1)} ' + 'raster=${(raster / 1000).toStringAsFixed(1)})', + ); + } + } + }); +} + +/// Schedules a timer every [_probePeriod] and reports how late it actually +/// fired. Lateness is time the UI isolate spent unable to run anything -- e.g. +/// a synchronous JSON serialize of the whole note log. +void _installEventLoopProbe() { + var expected = _clock.elapsedMicroseconds + _probePeriod.inMicroseconds; + void tick(Timer _) { + final now = _clock.elapsedMicroseconds; + final lateMs = (now - expected) / 1000; + if (lateMs > _blockedMs) { + stdout.writeln( + '[UI-BLOCKED $_at s] event loop stalled ' + '${lateMs.toStringAsFixed(1)}ms', + ); + } + expected = now + _probePeriod.inMicroseconds; + } + + _probe = Timer.periodic(_probePeriod, tick); +} + +/// Stops the probe. Only needed if the instrument outlives the app. +void disposeFrameStats() => _probe?.cancel(); diff --git a/lib/frame_stats_web.dart b/lib/frame_stats_web.dart new file mode 100644 index 0000000..841dba6 --- /dev/null +++ b/lib/frame_stats_web.dart @@ -0,0 +1,81 @@ +// coverage:ignore-file +// TEMPORARY diagnostic instrument, web variant. See frame_stats_io.dart. +// +// Armed at build time (`--dart-define=TODO_FRAME_STATS=1`) rather than by an +// environment variable, which a browser does not have. Reports to the devtools +// console. +import 'dart:async'; + +import 'dart:ui' show FramePhase; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; + +/// Whether the frame-stats instrument is armed for this build. +const frameStatsEnabled = bool.fromEnvironment('TODO_FRAME_STATS'); + +const _slowFrameMs = 8; +const _probePeriod = Duration(milliseconds: 16); +const _blockedMs = 24; + +final Stopwatch _clock = Stopwatch(); +Timer? _probe; +final List _cadenceUs = []; + +String get _at => (_clock.elapsedMilliseconds / 1000).toStringAsFixed(2); + +/// Installs the frame-timing and event-loop instruments. +void installFrameStats() { + if (!frameStatsEnabled) return; + _clock.start(); + debugPrint('[frame-stats web] armed'); + + SchedulerBinding.instance.addTimingsCallback((timings) { + for (final t in timings) { + final total = t.buildDuration.inMilliseconds + + t.rasterDuration.inMilliseconds; + if (total > _slowFrameMs) { + debugPrint( + '[SLOW-FRAME $_at s] total=${total}ms ' + '(build=${t.buildDuration.inMilliseconds} ' + 'raster=${t.rasterDuration.inMilliseconds})', + ); + } + // Cadence is the headline number for the >=100fps done-condition. + final vsync = t.timestampInMicroseconds(FramePhase.vsyncStart); + _cadenceUs.add(vsync); + } + _reportCadence(); + }); + + var expected = _clock.elapsedMicroseconds + _probePeriod.inMicroseconds; + _probe = Timer.periodic(_probePeriod, (_) { + final now = _clock.elapsedMicroseconds; + final lateMs = (now - expected) / 1000; + if (lateMs > _blockedMs) { + debugPrint('[UI-BLOCKED $_at s] ${lateMs.toStringAsFixed(1)}ms'); + } + expected = now + _probePeriod.inMicroseconds; + }); +} + +void _reportCadence() { + if (_cadenceUs.length < 120) return; + final deltas = []; + for (var i = 1; i < _cadenceUs.length; i++) { + deltas.add(_cadenceUs[i] - _cadenceUs[i - 1]); + } + deltas.sort(); + final p50 = deltas[deltas.length ~/ 2] / 1000; + final p95 = deltas[(deltas.length * 95) ~/ 100] / 1000; + debugPrint( + '[frame-stats web] cadence p50=${p50.toStringAsFixed(2)}ms ' + 'p95=${p95.toStringAsFixed(2)}ms (~${(1000 / p50).toStringAsFixed(1)} fps)', + ); + _cadenceUs.clear(); +} + +/// Stops the probe. +void disposeFrameStats() { + _probe?.cancel(); +} diff --git a/lib/main.dart b/lib/main.dart index e0eb60c..4d795fe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,10 @@ // coverage:ignore-file -// App bootstrap: wires platform DB paths (path_provider) into the repository -// and calls runApp. Exercised end-to-end by running the app, not unit tests. -import 'dart:io'; - -import 'package:flutter/foundation.dart'; +// App bootstrap: opens the platform's note store and calls runApp. Exercised +// end-to-end by running the app, not unit tests. import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/repository_factory.dart'; import 'package:todo/frame_stats.dart'; import 'package:todo/ui/capture_screen.dart'; @@ -17,15 +12,10 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); installFrameStats(); - // Desktop platforms need the FFI sqlite implementation initialised - // before any database is opened; mobile uses the bundled library. - if (!kIsWeb && (Platform.isLinux || Platform.isWindows || Platform.isMacOS)) { - sqfliteFfiInit(); - } - - final dir = await getApplicationSupportDirectory(); - final dbPath = p.join(dir.path, 'todo.db'); - final repository = await NoteRepository.open(dbPath); + // Where the log lives, and whether a legacy sqlite DB needs importing, is + // platform-specific and resolved by a conditional import: `dart:io` cannot be + // imported at all in a web compile, and the desktop app is now a web build. + final repository = await openRepository(); runApp(TodoApp(repository: repository)); } diff --git a/lib/sync/backlog_export.dart b/lib/sync/backlog_export.dart new file mode 100644 index 0000000..064f186 --- /dev/null +++ b/lib/sync/backlog_export.dart @@ -0,0 +1,9 @@ +/// Platform entry point for the explicit "Export notes" action. +/// +/// Conditional export because `dart:io` is unusable in a browser: mobile shares +/// a temp file, io desktop writes `~/todo/BACKLOG.md` directly, and the web +/// desktop app asks the local wrapper to write that same path. +library; + +export 'backlog_export_io.dart' + if (dart.library.js_interop) 'backlog_export_web.dart'; diff --git a/lib/sync/backlog_export_io.dart b/lib/sync/backlog_export_io.dart new file mode 100644 index 0000000..ffb877d --- /dev/null +++ b/lib/sync/backlog_export_io.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +/// Writes the Markdown export somewhere the user can get at it, and returns a +/// human-readable description of where it went. +/// +/// On mobile this opens the system share sheet; on a `dart:io` desktop host it +/// writes the canonical `~/todo/BACKLOG.md`. +Future exportBacklog(String markdown, int noteCount) async { + // coverage:ignore-start + // Mobile-only share path: Platform.isAndroid/isIOS are always false on the + // Linux test host, so these lines are structurally unreachable in CI and + // excluded from the coverage denominator. Verified on-device. + if (Platform.isAndroid || Platform.isIOS) { + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/todo-backlog.md'); + await file.writeAsString(markdown); + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path, mimeType: 'text/markdown')], + subject: 'todo backlog ($noteCount notes)', + ), + ); + return 'Shared $noteCount notes'; + } + // coverage:ignore-end + final home = Platform.environment['HOME'] ?? Directory.current.path; + final dir = Directory('$home/todo'); + if (!dir.existsSync()) dir.createSync(recursive: true); + final file = File('${dir.path}/BACKLOG.md'); + await file.writeAsString(markdown); + return 'Exported $noteCount notes to ${file.path}'; +} diff --git a/lib/sync/backlog_export_web.dart b/lib/sync/backlog_export_web.dart new file mode 100644 index 0000000..2717eab --- /dev/null +++ b/lib/sync/backlog_export_web.dart @@ -0,0 +1,11 @@ +import 'package:todo/data/desktop_backup_client.dart'; +import 'package:todo/sync/desktop_wrapper.dart'; + +/// Writes the Markdown export through the desktop wrapper, so an explicit +/// Export still lands at the canonical `~/todo/BACKLOG.md` path the user's +/// tooling reads — the browser itself cannot write there. +Future exportBacklog(String markdown, int noteCount) async { + final client = DesktopBackupClient(baseUrl: desktopWrapperOrigin); + await client.writeBacklog(markdown); + return 'Exported $noteCount notes to ~/todo/BACKLOG.md'; +} diff --git a/lib/sync/desktop_wrapper.dart b/lib/sync/desktop_wrapper.dart new file mode 100644 index 0000000..e60cf5e --- /dev/null +++ b/lib/sync/desktop_wrapper.dart @@ -0,0 +1,11 @@ +/// The fixed origin the desktop wrapper serves on. +/// +/// **Do not change this casually.** `localStorage` (which holds the GitHub +/// token) and IndexedDB (which holds the note log) are both keyed by origin, so +/// changing the port silently logs the user out and hides their local notes +/// behind an origin they no longer visit. The wrapper script and +/// `install_arch.sh` must use the same value. +const desktopWrapperPort = 8730; + +/// Origin of the desktop wrapper, e.g. `http://localhost:8730`. +const desktopWrapperOrigin = 'http://localhost:$desktopWrapperPort'; diff --git a/lib/sync/local_backup_factory.dart b/lib/sync/local_backup_factory.dart new file mode 100644 index 0000000..240954a --- /dev/null +++ b/lib/sync/local_backup_factory.dart @@ -0,0 +1,8 @@ +/// Platform entry point for building the Markdown backlog backup. +/// +/// Conditional export because `dart:io` is unusable in a browser: mobile writes +/// the file directly, the web desktop app hands it to the local wrapper. +library; + +export 'local_backup_factory_io.dart' + if (dart.library.js_interop) 'local_backup_factory_web.dart'; diff --git a/lib/sync/local_backup_factory_io.dart b/lib/sync/local_backup_factory_io.dart new file mode 100644 index 0000000..2e91cf1 --- /dev/null +++ b/lib/sync/local_backup_factory_io.dart @@ -0,0 +1,36 @@ +// coverage:ignore-file +// Real-filesystem platform wiring, extracted verbatim from the +// `coverage:ignore` block it previously lived in inside capture_screen.dart. +// It resolves paths from the actual HOME/documents directory, so exercising it +// under test would write outside the sandbox; tests inject an in-memory +// LocalBackup instead. Verified by running the app. +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/local_backup.dart'; + +/// Builds the platform [LocalBackup] on a `dart:io` host (Android). +/// +/// On mobile this lives in the app's documents directory, where Android Auto +/// Backup can pick it up. +LocalBackup createLocalBackup(NoteRepository repository) { + Future backupFile() async { + if (Platform.isAndroid || Platform.isIOS) { + final dir = await getApplicationDocumentsDirectory(); + return File('${dir.path}/todo-backlog.md'); + } + final home = Platform.environment['HOME'] ?? Directory.current.path; + final dir = Directory('$home/todo')..createSync(recursive: true); + return File('${dir.path}/BACKLOG.md'); + } + + return LocalBackup( + fetch: repository.listNotes, + reader: () async { + final file = await backupFile(); + return file.existsSync() ? file.readAsString() : null; + }, + writer: (markdown) async => (await backupFile()).writeAsString(markdown), + ); +} diff --git a/lib/sync/local_backup_factory_web.dart b/lib/sync/local_backup_factory_web.dart new file mode 100644 index 0000000..11824a9 --- /dev/null +++ b/lib/sync/local_backup_factory_web.dart @@ -0,0 +1,21 @@ +import 'package:todo/data/desktop_backup_client.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/desktop_wrapper.dart'; +import 'package:todo/sync/local_backup.dart'; + +/// Builds the [LocalBackup] for the browser-hosted desktop app. +/// +/// A browser cannot write to the filesystem, so the wrapper that serves the +/// build owns the file. This keeps `~/todo/BACKLOG.md` current — the user's +/// tooling and the `todo` MCP server read it, and without this the move to a +/// web build would let it silently go stale. +LocalBackup createLocalBackup(NoteRepository repository) { + final client = DesktopBackupClient(baseUrl: desktopWrapperOrigin); + return LocalBackup( + fetch: repository.listNotes, + // Recovery reads the same file back through the wrapper, so a cleared + // browser profile can still restore from the on-disk backlog. + reader: client.readBacklog, + writer: client.writeBacklog, + ); +} diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index a760124..bc20761 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -1,16 +1,15 @@ import 'dart:async'; -import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/data/note_template.dart'; import 'package:todo/frame_stats.dart'; import 'package:todo/sync/local_backup.dart'; +import 'package:todo/sync/local_backup_factory.dart'; import 'package:todo/sync/sync_service.dart'; import 'package:todo/sync/sync_settings.dart'; import 'package:todo/ui/note_editor.dart'; @@ -197,26 +196,8 @@ class _CaptureScreenState extends State // (the path the user's workflow already reads), or the app documents dir on // mobile (which Android Auto Backup includes). Exercised by running the app; // tests inject an in-memory LocalBackup instead. - static LocalBackup _platformLocalBackup(NoteRepository repository) { - Future backupFile() async { - if (Platform.isAndroid || Platform.isIOS) { - final dir = await getApplicationDocumentsDirectory(); - return File('${dir.path}/todo-backlog.md'); - } - final home = Platform.environment['HOME'] ?? Directory.current.path; - final dir = Directory('$home/todo')..createSync(recursive: true); - return File('${dir.path}/BACKLOG.md'); - } - - return LocalBackup( - fetch: repository.listNotes, - reader: () async { - final file = await backupFile(); - return file.existsSync() ? file.readAsString() : null; - }, - writer: (markdown) async => (await backupFile()).writeAsString(markdown), - ); - } + static LocalBackup _platformLocalBackup(NoteRepository repository) => + createLocalBackup(repository); // coverage:ignore-end @override diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index a82ec33..eafa43c 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -1,14 +1,12 @@ import 'dart:async'; -import 'dart:io'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/backlog_export.dart'; import 'package:todo/sync/github_device_auth.dart'; import 'package:todo/sync/notes_markdown.dart'; import 'package:todo/sync/sync_service.dart'; @@ -178,33 +176,8 @@ class _SettingsScreenState extends State { final notes = await widget.repository.listNotes(); final markdown = NotesMarkdown.export(notes); - // coverage:ignore-start - // Mobile-only share path: Platform.isAndroid/isIOS are always false on - // the Linux test host, so these lines are structurally unreachable in - // CI and excluded from the coverage denominator. Verified on-device. - if (Platform.isAndroid || Platform.isIOS) { - final dir = await getTemporaryDirectory(); - final file = File('${dir.path}/todo-backlog.md'); - await file.writeAsString(markdown); - await SharePlus.instance.share( - ShareParams( - files: [XFile(file.path, mimeType: 'text/markdown')], - subject: 'todo backlog (${notes.length} notes)', - ), - ); - } else { - // coverage:ignore-end - final home = Platform.environment['HOME'] ?? Directory.current.path; - final dir = Directory('$home/todo'); - if (!dir.existsSync()) dir.createSync(recursive: true); - final file = File('${dir.path}/BACKLOG.md'); - await file.writeAsString(markdown); - if (mounted) { - setState( - () => _status = 'Exported ${notes.length} notes to ${file.path}', - ); - } - } + final outcome = await exportBacklog(markdown, notes.length); + if (mounted) setState(() => _status = outcome); } on Exception catch (e) { if (mounted) setState(() => _status = 'Export failed: $e'); } diff --git a/pubspec.lock b/pubspec.lock index 063038d..eb13eac 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -110,10 +110,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+4" crypto: dependency: transitive description: @@ -134,10 +134,10 @@ packages: dependency: transitive description: name: dev_build - sha256: "21df3f741f0f5fc7c45f618e68e206b9f021b1adf83db4004dfaa032996cb0c1" + sha256: f639b36c1cf845c11b506b562e162eca84f2cb368cf9cca48117069f881d4be8 url: "https://pub.dev" source: hosted - version: "1.1.8+2" + version: "1.1.8+3" fake_async: dependency: "direct dev" description: @@ -345,6 +345,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + idb_shim: + dependency: "direct main" + description: + name: idb_shim + sha256: "3448298f244bc76a14aca71461eeab6add2b8a877930521c42c2e8b71b644590" + url: "https://pub.dev" + source: hosted + version: "2.9.6+2" image: dependency: transitive description: @@ -445,10 +453,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96 + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 url: "https://pub.dev" source: hosted - version: "0.19.1" + version: "0.19.2" objective_c: dependency: transitive description: @@ -477,10 +485,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -501,18 +509,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -565,10 +573,10 @@ packages: dependency: transitive description: name: process_run - sha256: "3af4220bdee974fd6305caab920c8af07cd846696f825e6e84fb4e123b13fde9" + sha256: dadbf4986ddab79086a107b2f893bacd580a523453f76238bc0c13a9faa982af url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "1.3.4+1" pub_semver: dependency: transitive description: @@ -585,14 +593,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + sembast: + dependency: transitive + description: + name: sembast + sha256: a58b26925e23071cf0f4754d8449aabe829e9a9930a872fb18e6ae4c2c88e025 + url: "https://pub.dev" + source: hosted + version: "3.8.9+1" share_plus: dependency: "direct main" description: name: share_plus - sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0 + sha256: "02180b01c1237b9706b663d9402b2cf2402b3407f48cce99cc19e3200f095b8a" url: "https://pub.dev" source: hosted - version: "13.1.0" + version: "13.2.1" share_plus_platform_interface: dependency: transitive description: @@ -613,10 +629,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5" + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" url: "https://pub.dev" source: hosted - version: "2.4.26" + version: "2.4.27" shared_preferences_foundation: dependency: transitive description: @@ -706,10 +722,10 @@ packages: dependency: transitive description: name: sqlite3 - sha256: "37356bcb56ce0d9404d602c41e4bdb7765e7e9732a3e47adb3d98c556a6abdad" + sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4 url: "https://pub.dev" source: hosted - version: "3.3.3" + version: "3.5.0" sqlite_crdt: dependency: "direct main" description: @@ -850,10 +866,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b7285c7..337176f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: # libsecret) instead of plaintext SharedPreferences. flutter_secure_storage: ^10.3.1 http: ^1.6.0 + idb_shim: ^2.9.6+2 path: ^1.9.1 path_provider: ^2.1.5 share_plus: ^13.1.0 diff --git a/test/fake_note_repository.dart b/test/fake_note_repository.dart index 94462e7..b0fba7e 100644 --- a/test/fake_note_repository.dart +++ b/test/fake_note_repository.dart @@ -102,6 +102,9 @@ class FakeNoteRepository implements NoteRepository { @override Future importLog(Log remote) async {} + @override + Future seedFrom(Log log) async {} + @override Future close() async { await _controller.close(); diff --git a/test/note_repository_test.dart b/test/note_repository_test.dart index 9dedd92..fc39cee 100644 --- a/test/note_repository_test.dart +++ b/test/note_repository_test.dart @@ -7,8 +7,10 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; // SqliteCrdt is needed to seed the legacy DB in migration tests; hide its Hlc // so the crdt_sync Hlc/Record are unambiguous. import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc; +import 'package:path/path.dart' as p; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; +import 'package:todo/data/repository_factory_io.dart'; void main() { setUpAll(sqfliteFfiInit); @@ -292,7 +294,7 @@ void main() { test('v2→v3 migration backfills priority 0 to medium', () async { SharedPreferences.setMockInitialValues({}); final dir = await Directory.systemTemp.createTemp('todo_migration'); - final path = '${dir.path}/notes.db'; + final path = '${dir.path}/todo.db'; addTearDown(() => dir.delete(recursive: true)); // Build a v2 database (status column present, no priority backfill) and @@ -323,7 +325,7 @@ void main() { await v2.close(); // Reopening through the repository runs onUpgrade to v3. - final repo = await NoteRepository.open(path); + final repo = await openRepositoryIn(p.dirname(path)); addTearDown(repo.close); final notes = await repo.listNotes(); @@ -334,7 +336,7 @@ void main() { test('v1→v2 migration adds the status column with a default', () async { SharedPreferences.setMockInitialValues({}); final dir = await Directory.systemTemp.createTemp('todo_migration_v1'); - final path = '${dir.path}/notes.db'; + final path = '${dir.path}/todo.db'; addTearDown(() => dir.delete(recursive: true)); // v1 schema predates the status column entirely. @@ -363,7 +365,7 @@ void main() { await v1.close(); // Reopening runs onUpgrade v1→v2 (adds status, default todo) then v2→v3. - final repo = await NoteRepository.open(path); + final repo = await openRepositoryIn(p.dirname(path)); addTearDown(repo.close); final notes = await repo.listNotes(); expect(notes.single.id, 'old'); @@ -453,7 +455,7 @@ void main() { addTearDown(() => dir.delete(recursive: true)); final path = await seedLegacyDb(dir.path); - final repo = await NoteRepository.open(path); + final repo = await openRepositoryIn(p.dirname(path)); addTearDown(repo.close); // The live note migrated with its fields intact... @@ -469,7 +471,7 @@ void main() { // survives, and the (still-present) legacy DB is not re-imported. await repo.upsert(note('new', 'added post-migration')); await repo.close(); - final reopened = await NoteRepository.open(path); + final reopened = await openRepositoryIn(p.dirname(path)); addTearDown(reopened.close); final texts = (await reopened.listNotes()).map((n) => n.text).toSet(); expect(texts, {'kept idea', 'added post-migration'}); @@ -480,7 +482,7 @@ void main() { final dir = await Directory.systemTemp.createTemp('todo_fresh'); addTearDown(() => dir.delete(recursive: true)); - final repo = await NoteRepository.open('${dir.path}/todo.db'); + final repo = await openRepositoryIn(dir.path); addTearDown(repo.close); expect(await repo.listNotes(), isEmpty); diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..cd65376 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + todo + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..3a7f44f --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "todo", + "short_name": "todo", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}