Make the app compile and run for web, behind platform seams

First step of moving the desktop app off Flutter's Linux embedder, which tops
out at ~20fps at 4K (see docs/desktop-performance-findings.md). Android is
unaffected and keeps every existing code path.

The blocker was that `dart:io` was imported by note_repository, capture_screen
and settings_screen. On web it compiles to a stub and then throws at runtime --
the first web build rendered a blank white window. So each `dart:io` use now
sits behind a conditional export, with the shared logic hoisted into pure Dart:

- note_repository loses `dart:io`/sqlite entirely. Platform wiring moves to
  repository_factory{,_io,_web}, and the legacy sqlite import moves to its own
  io-only library. The HLC-from-updated_at seeding is preserved verbatim, so
  independently-migrating devices still resolve pre-cutover edits by real edit
  time.
- Notes persist to IndexedDB on web, not localStorage: the log is the primary
  copy of the backlog and localStorage caps at ~5-10MB and is evicted eagerly.
- BACKLOG.md and the explicit Export keep working through a local wrapper
  (added next). A browser cannot write `~/todo/BACKLOG.md`, and the user's
  tooling plus the todo MCP server read that path, so leaving it to rot was not
  an option. Writes are best-effort so the app still works with no wrapper.
- Notes are also mirrored to the wrapper's on-disk copy, so a wiped Chrome
  profile is not a total-loss event for notes that have not reached GitHub yet.

`openRepository` is split so `openRepositoryIn` keeps the migration tests; the
suite stays at 174 green and 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-07-20 13:14:03 +02:00
parent c740cac2f0
commit 66143bdfe3
32 changed files with 820 additions and 302 deletions

View File

@ -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

View File

@ -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<void> writeBacklog(String markdown) =>
_post('/backup/backlog', markdown);
/// Writes the serialised note log to disk via the wrapper.
Future<void> writeLog(String json) => _post('/backup/log', json);
/// Reads the wrapper's on-disk copy of the log, or null if unavailable.
Future<String?> readLog() => _get('/backup/log');
/// Reads the on-disk Markdown backlog, or null if unavailable.
Future<String?> readBacklog() => _get('/backup/backlog');
Future<String?> _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<void> _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.
}
}
}

View File

@ -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/<id>` 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 = <String, Record>{};
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<void> _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<void> _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');
}
}
}

View File

@ -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<NoteRepository> 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/<id> 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<NoteRepository> 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<void> seedFrom(Log log) async {
if (log.isNotEmpty) await _store.replaceAll(log);
}
/// Opens a transient in-memory log; intended for tests.
static Future<NoteRepository> openInMemory({
String nodeId = 'test-node',
@ -132,39 +115,30 @@ 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<void> _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',
);
final log = <String, Record>{};
for (final row in rows) {
final note = Note.fromRow(row);
/// 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,
);
final deleted = (row['is_deleted'] as int? ?? 0) != 0;
log[note.id] = Record(
return 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<void> _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<void> _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).

View File

@ -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';

View File

@ -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<NoteRepository> 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<NoteRepository> 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);
}

View File

@ -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<NoteRepository> 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);
}

View File

@ -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<Database> openDatabase(IdbFactory factory) => factory.open(
databaseName,
version: 1,
onUpgradeNeeded: (event) {
final db = event.database;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
},
);
@override
Future<String?> 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<void> 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<void>.value());
}
}

View File

@ -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';

84
lib/frame_stats_io.dart Normal file
View File

@ -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();

81
lib/frame_stats_web.dart Normal file
View File

@ -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<int> _cadenceUs = <int>[];
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 = <int>[];
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();
}

View File

@ -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<void> 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));
}

View File

@ -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';

View File

@ -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<String> 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}';
}

View File

@ -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<String> exportBacklog(String markdown, int noteCount) async {
final client = DesktopBackupClient(baseUrl: desktopWrapperOrigin);
await client.writeBacklog(markdown);
return 'Exported $noteCount notes to ~/todo/BACKLOG.md';
}

View File

@ -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';

View File

@ -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';

View File

@ -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<File> 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),
);
}

View File

@ -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,
);
}

View File

@ -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<CaptureScreen>
// (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<File> 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

View File

@ -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<SettingsScreen> {
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');
}

View File

@ -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:

View File

@ -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

View File

@ -102,6 +102,9 @@ class FakeNoteRepository implements NoteRepository {
@override
Future<void> importLog(Log remote) async {}
@override
Future<void> seedFrom(Log log) async {}
@override
Future<void> close() async {
await _controller.close();

View File

@ -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);

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

BIN
web/icons/Icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
web/icons/Icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

46
web/index.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="todo">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>todo</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

35
web/manifest.json Normal file
View File

@ -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"
}
]
}