mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:27:01 +01:00
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:
parent
c740cac2f0
commit
66143bdfe3
15
.metadata
15
.metadata
@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
|
revision: "ee80f08bbf97172ec030b8751ceab557177a34a6"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@ -13,14 +13,11 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||||
- platform: android
|
- platform: web
|
||||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6
|
||||||
- platform: linux
|
|
||||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
|
||||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
57
lib/data/desktop_backup_client.dart
Normal file
57
lib/data/desktop_backup_client.dart
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
lib/data/legacy_sqlite_migration.dart
Normal file
90
lib/data/legacy_sqlite_migration.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
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: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.
|
/// How the history list should be ordered.
|
||||||
enum NoteSort {
|
enum NoteSort {
|
||||||
@ -77,52 +75,37 @@ class NoteRepository {
|
|||||||
static const _fCreatedAt = 'created_at';
|
static const _fCreatedAt = 'created_at';
|
||||||
static const _fUpdatedAt = 'updated_at';
|
static const _fUpdatedAt = 'updated_at';
|
||||||
|
|
||||||
static const _kNodeId = 'crdt.nodeId';
|
/// SharedPreferences key holding this device's stable CRDT node id.
|
||||||
static const _kMigrated = 'crdt.migratedFromSqlite';
|
static const kNodeId = 'crdt.nodeId';
|
||||||
static const _logFileName = 'todo_notes.json';
|
|
||||||
|
|
||||||
/// 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,
|
/// Platform wiring — where the log lives, and the one-time legacy sqlite
|
||||||
/// idempotent import of the existing `sqlite_crdt` database at [path] into
|
/// import — belongs to the callers in `repository_factory_*.dart`, so that
|
||||||
/// the new log (guarded by a persisted "migrated" flag), so no backlog is
|
/// this class stays free of `dart:io` and can compile for web.
|
||||||
/// lost. The legacy DB file is left untouched as a safety net.
|
static Future<NoteRepository> openWith({
|
||||||
static Future<NoteRepository> open(String path) async {
|
required LogPersistence persistence,
|
||||||
final prefs = await SharedPreferences.getInstance();
|
required String nodeId,
|
||||||
final logFile = File(p.join(p.dirname(path), _logFileName));
|
}) async {
|
||||||
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);
|
|
||||||
}
|
|
||||||
final store = LogStore(persistence: persistence, nodeId: nodeId);
|
final store = LogStore(persistence: persistence, nodeId: nodeId);
|
||||||
await store.load();
|
await store.load();
|
||||||
return NoteRepository._(store, nodeId);
|
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.
|
/// Opens a transient in-memory log; intended for tests.
|
||||||
static Future<NoteRepository> openInMemory({
|
static Future<NoteRepository> openInMemory({
|
||||||
String nodeId = 'test-node',
|
String nodeId = 'test-node',
|
||||||
@ -132,39 +115,30 @@ class NoteRepository {
|
|||||||
return NoteRepository._(store, nodeId);
|
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",
|
/// 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
|
/// 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
|
/// wins by real edit time under per-field last-writer-wins instead of being
|
||||||
/// resolved arbitrarily.
|
/// resolved arbitrarily. Public because the migration that calls it lives in
|
||||||
static Future<void> _seedFromLegacy(
|
/// the io-only library, which cannot reach private members here.
|
||||||
SqliteCrdt legacy,
|
static Record legacyRecordFor(
|
||||||
LogStore store,
|
Note note,
|
||||||
String nodeId,
|
String nodeId, {
|
||||||
) async {
|
bool deleted = false,
|
||||||
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);
|
|
||||||
final hlc = Hlc(
|
final hlc = Hlc(
|
||||||
wallTimeMs: note.updatedAt.millisecondsSinceEpoch,
|
wallTimeMs: note.updatedAt.millisecondsSinceEpoch,
|
||||||
counter: 0,
|
counter: 0,
|
||||||
nodeId: nodeId,
|
nodeId: nodeId,
|
||||||
);
|
);
|
||||||
final deleted = (row['is_deleted'] as int? ?? 0) != 0;
|
return Record(
|
||||||
log[note.id] = Record(
|
|
||||||
id: note.id,
|
id: note.id,
|
||||||
fields: _fieldsFor(note, hlc),
|
fields: _fieldsFor(note, hlc),
|
||||||
deleted: deleted,
|
deleted: deleted,
|
||||||
deletedHlc: deleted ? hlc : null,
|
deletedHlc: deleted ? hlc : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (log.isNotEmpty) await store.replaceAll(log);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserts a new note or updates the existing one with the same [id].
|
/// 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.
|
/// Midnight (local) of [t]'s calendar day.
|
||||||
static DateTime _startOfDay(DateTime t) => DateTime(t.year, t.month, t.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).
|
/// In-memory [LogPersistence] for [NoteRepository.openInMemory] (tests).
|
||||||
|
|||||||
9
lib/data/repository_factory.dart
Normal file
9
lib/data/repository_factory.dart
Normal 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';
|
||||||
64
lib/data/repository_factory_io.dart
Normal file
64
lib/data/repository_factory_io.dart
Normal 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);
|
||||||
|
}
|
||||||
27
lib/data/repository_factory_web.dart
Normal file
27
lib/data/repository_factory_web.dart
Normal 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);
|
||||||
|
}
|
||||||
69
lib/data/web_log_persistence.dart
Normal file
69
lib/data/web_log_persistence.dart
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,84 +1,10 @@
|
|||||||
// coverage:ignore-file
|
/// Platform entry point for the temporary frame instrument.
|
||||||
// TEMPORARY diagnostic instrument (desktop interaction-lag investigation).
|
///
|
||||||
// Guarded by TODO_FRAME_STATS=1 so it is inert in normal use.
|
/// Conditional export because the io version writes to `stdout` and reads
|
||||||
//
|
/// `Platform.environment`, neither of which exists in a browser. The web
|
||||||
// v3 adds an event-loop blocking monitor, which is the decisive instrument.
|
/// version reports to the devtools console instead and is armed at build time
|
||||||
// Frame gaps are ambiguous in a mostly-static UI: no frame is produced when
|
/// with `--dart-define=TODO_FRAME_STATS=1`.
|
||||||
// nothing needs drawing, so an idle user looks identical to a stalled isolate.
|
library;
|
||||||
// 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';
|
export 'frame_stats_io.dart'
|
||||||
|
if (dart.library.js_interop) 'frame_stats_web.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();
|
|
||||||
|
|||||||
84
lib/frame_stats_io.dart
Normal file
84
lib/frame_stats_io.dart
Normal 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
81
lib/frame_stats_web.dart
Normal 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();
|
||||||
|
}
|
||||||
@ -1,15 +1,10 @@
|
|||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// App bootstrap: wires platform DB paths (path_provider) into the repository
|
// App bootstrap: opens the platform's note store and calls runApp. Exercised
|
||||||
// and calls runApp. Exercised end-to-end by running the app, not unit tests.
|
// end-to-end by running the app, not unit tests.
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
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/note_repository.dart';
|
||||||
|
import 'package:todo/data/repository_factory.dart';
|
||||||
import 'package:todo/frame_stats.dart';
|
import 'package:todo/frame_stats.dart';
|
||||||
import 'package:todo/ui/capture_screen.dart';
|
import 'package:todo/ui/capture_screen.dart';
|
||||||
|
|
||||||
@ -17,15 +12,10 @@ Future<void> main() async {
|
|||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
installFrameStats();
|
installFrameStats();
|
||||||
|
|
||||||
// Desktop platforms need the FFI sqlite implementation initialised
|
// Where the log lives, and whether a legacy sqlite DB needs importing, is
|
||||||
// before any database is opened; mobile uses the bundled library.
|
// platform-specific and resolved by a conditional import: `dart:io` cannot be
|
||||||
if (!kIsWeb && (Platform.isLinux || Platform.isWindows || Platform.isMacOS)) {
|
// imported at all in a web compile, and the desktop app is now a web build.
|
||||||
sqfliteFfiInit();
|
final repository = await openRepository();
|
||||||
}
|
|
||||||
|
|
||||||
final dir = await getApplicationSupportDirectory();
|
|
||||||
final dbPath = p.join(dir.path, 'todo.db');
|
|
||||||
final repository = await NoteRepository.open(dbPath);
|
|
||||||
|
|
||||||
runApp(TodoApp(repository: repository));
|
runApp(TodoApp(repository: repository));
|
||||||
}
|
}
|
||||||
|
|||||||
9
lib/sync/backlog_export.dart
Normal file
9
lib/sync/backlog_export.dart
Normal 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';
|
||||||
35
lib/sync/backlog_export_io.dart
Normal file
35
lib/sync/backlog_export_io.dart
Normal 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}';
|
||||||
|
}
|
||||||
11
lib/sync/backlog_export_web.dart
Normal file
11
lib/sync/backlog_export_web.dart
Normal 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';
|
||||||
|
}
|
||||||
11
lib/sync/desktop_wrapper.dart
Normal file
11
lib/sync/desktop_wrapper.dart
Normal 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';
|
||||||
8
lib/sync/local_backup_factory.dart
Normal file
8
lib/sync/local_backup_factory.dart
Normal 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';
|
||||||
36
lib/sync/local_backup_factory_io.dart
Normal file
36
lib/sync/local_backup_factory_io.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
21
lib/sync/local_backup_factory_web.dart
Normal file
21
lib/sync/local_backup_factory_web.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,16 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
import 'package:todo/frame_stats.dart';
|
import 'package:todo/frame_stats.dart';
|
||||||
import 'package:todo/sync/local_backup.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_service.dart';
|
||||||
import 'package:todo/sync/sync_settings.dart';
|
import 'package:todo/sync/sync_settings.dart';
|
||||||
import 'package:todo/ui/note_editor.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
|
// (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;
|
// mobile (which Android Auto Backup includes). Exercised by running the app;
|
||||||
// tests inject an in-memory LocalBackup instead.
|
// tests inject an in-memory LocalBackup instead.
|
||||||
static LocalBackup _platformLocalBackup(NoteRepository repository) {
|
static LocalBackup _platformLocalBackup(NoteRepository repository) =>
|
||||||
Future<File> backupFile() async {
|
createLocalBackup(repository);
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// coverage:ignore-end
|
// coverage:ignore-end
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:file_selector/file_selector.dart';
|
import 'package:file_selector/file_selector.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:http/http.dart' as http;
|
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/data/note_repository.dart';
|
||||||
|
import 'package:todo/sync/backlog_export.dart';
|
||||||
import 'package:todo/sync/github_device_auth.dart';
|
import 'package:todo/sync/github_device_auth.dart';
|
||||||
import 'package:todo/sync/notes_markdown.dart';
|
import 'package:todo/sync/notes_markdown.dart';
|
||||||
import 'package:todo/sync/sync_service.dart';
|
import 'package:todo/sync/sync_service.dart';
|
||||||
@ -178,33 +176,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final notes = await widget.repository.listNotes();
|
final notes = await widget.repository.listNotes();
|
||||||
final markdown = NotesMarkdown.export(notes);
|
final markdown = NotesMarkdown.export(notes);
|
||||||
|
|
||||||
// coverage:ignore-start
|
final outcome = await exportBacklog(markdown, notes.length);
|
||||||
// Mobile-only share path: Platform.isAndroid/isIOS are always false on
|
if (mounted) setState(() => _status = outcome);
|
||||||
// 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}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
if (mounted) setState(() => _status = 'Export failed: $e');
|
if (mounted) setState(() => _status = 'Export failed: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
60
pubspec.lock
60
pubspec.lock
@ -110,10 +110,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: cross_file
|
name: cross_file
|
||||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+2"
|
version: "0.3.5+4"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -134,10 +134,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dev_build
|
name: dev_build
|
||||||
sha256: "21df3f741f0f5fc7c45f618e68e206b9f021b1adf83db4004dfaa032996cb0c1"
|
sha256: f639b36c1cf845c11b506b562e162eca84f2cb368cf9cca48117069f881d4be8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.8+2"
|
version: "1.1.8+3"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -345,6 +345,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -445,10 +453,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: native_toolchain_c
|
name: native_toolchain_c
|
||||||
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.19.1"
|
version: "0.19.2"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -477,10 +485,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.5"
|
version: "2.1.6"
|
||||||
path_provider_android:
|
path_provider_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -501,18 +509,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_linux
|
name: path_provider_linux
|
||||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
version: "2.2.2"
|
||||||
path_provider_platform_interface:
|
path_provider_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_platform_interface
|
name: path_provider_platform_interface
|
||||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.3"
|
||||||
path_provider_windows:
|
path_provider_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -565,10 +573,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: process_run
|
name: process_run
|
||||||
sha256: "3af4220bdee974fd6305caab920c8af07cd846696f825e6e84fb4e123b13fde9"
|
sha256: dadbf4986ddab79086a107b2f893bacd580a523453f76238bc0c13a9faa982af
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.4"
|
version: "1.3.4+1"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -585,14 +593,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
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:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: share_plus
|
name: share_plus
|
||||||
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
|
sha256: "02180b01c1237b9706b663d9402b2cf2402b3407f48cce99cc19e3200f095b8a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "13.1.0"
|
version: "13.2.1"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -613,10 +629,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
|
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.26"
|
version: "2.4.27"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -706,10 +722,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "37356bcb56ce0d9404d602c41e4bdb7765e7e9732a3e47adb3d98c556a6abdad"
|
sha256: c73fd75df1332d76a6257f4823ae4df9c791f522b97e4a60cbcad214de1becf4
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.3"
|
version: "3.5.0"
|
||||||
sqlite_crdt:
|
sqlite_crdt:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -850,10 +866,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.3"
|
version: "4.6.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -47,6 +47,7 @@ dependencies:
|
|||||||
# libsecret) instead of plaintext SharedPreferences.
|
# libsecret) instead of plaintext SharedPreferences.
|
||||||
flutter_secure_storage: ^10.3.1
|
flutter_secure_storage: ^10.3.1
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
|
idb_shim: ^2.9.6+2
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
|
|||||||
@ -102,6 +102,9 @@ class FakeNoteRepository implements NoteRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> importLog(Log remote) async {}
|
Future<void> importLog(Log remote) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> seedFrom(Log log) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _controller.close();
|
await _controller.close();
|
||||||
|
|||||||
@ -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
|
// SqliteCrdt is needed to seed the legacy DB in migration tests; hide its Hlc
|
||||||
// so the crdt_sync Hlc/Record are unambiguous.
|
// so the crdt_sync Hlc/Record are unambiguous.
|
||||||
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
|
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.dart';
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
|
import 'package:todo/data/repository_factory_io.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(sqfliteFfiInit);
|
setUpAll(sqfliteFfiInit);
|
||||||
@ -292,7 +294,7 @@ void main() {
|
|||||||
test('v2→v3 migration backfills priority 0 to medium', () async {
|
test('v2→v3 migration backfills priority 0 to medium', () async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final dir = await Directory.systemTemp.createTemp('todo_migration');
|
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));
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
|
||||||
// Build a v2 database (status column present, no priority backfill) and
|
// Build a v2 database (status column present, no priority backfill) and
|
||||||
@ -323,7 +325,7 @@ void main() {
|
|||||||
await v2.close();
|
await v2.close();
|
||||||
|
|
||||||
// Reopening through the repository runs onUpgrade to v3.
|
// Reopening through the repository runs onUpgrade to v3.
|
||||||
final repo = await NoteRepository.open(path);
|
final repo = await openRepositoryIn(p.dirname(path));
|
||||||
addTearDown(repo.close);
|
addTearDown(repo.close);
|
||||||
final notes = await repo.listNotes();
|
final notes = await repo.listNotes();
|
||||||
|
|
||||||
@ -334,7 +336,7 @@ void main() {
|
|||||||
test('v1→v2 migration adds the status column with a default', () async {
|
test('v1→v2 migration adds the status column with a default', () async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
final dir = await Directory.systemTemp.createTemp('todo_migration_v1');
|
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));
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
|
||||||
// v1 schema predates the status column entirely.
|
// v1 schema predates the status column entirely.
|
||||||
@ -363,7 +365,7 @@ void main() {
|
|||||||
await v1.close();
|
await v1.close();
|
||||||
|
|
||||||
// Reopening runs onUpgrade v1→v2 (adds status, default todo) then v2→v3.
|
// 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);
|
addTearDown(repo.close);
|
||||||
final notes = await repo.listNotes();
|
final notes = await repo.listNotes();
|
||||||
expect(notes.single.id, 'old');
|
expect(notes.single.id, 'old');
|
||||||
@ -453,7 +455,7 @@ void main() {
|
|||||||
addTearDown(() => dir.delete(recursive: true));
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
final path = await seedLegacyDb(dir.path);
|
final path = await seedLegacyDb(dir.path);
|
||||||
|
|
||||||
final repo = await NoteRepository.open(path);
|
final repo = await openRepositoryIn(p.dirname(path));
|
||||||
addTearDown(repo.close);
|
addTearDown(repo.close);
|
||||||
|
|
||||||
// The live note migrated with its fields intact...
|
// 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.
|
// survives, and the (still-present) legacy DB is not re-imported.
|
||||||
await repo.upsert(note('new', 'added post-migration'));
|
await repo.upsert(note('new', 'added post-migration'));
|
||||||
await repo.close();
|
await repo.close();
|
||||||
final reopened = await NoteRepository.open(path);
|
final reopened = await openRepositoryIn(p.dirname(path));
|
||||||
addTearDown(reopened.close);
|
addTearDown(reopened.close);
|
||||||
final texts = (await reopened.listNotes()).map((n) => n.text).toSet();
|
final texts = (await reopened.listNotes()).map((n) => n.text).toSet();
|
||||||
expect(texts, {'kept idea', 'added post-migration'});
|
expect(texts, {'kept idea', 'added post-migration'});
|
||||||
@ -480,7 +482,7 @@ void main() {
|
|||||||
final dir = await Directory.systemTemp.createTemp('todo_fresh');
|
final dir = await Directory.systemTemp.createTemp('todo_fresh');
|
||||||
addTearDown(() => dir.delete(recursive: true));
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
|
||||||
final repo = await NoteRepository.open('${dir.path}/todo.db');
|
final repo = await openRepositoryIn(dir.path);
|
||||||
addTearDown(repo.close);
|
addTearDown(repo.close);
|
||||||
|
|
||||||
expect(await repo.listNotes(), isEmpty);
|
expect(await repo.listNotes(), isEmpty);
|
||||||
|
|||||||
BIN
web/favicon.png
Normal file
BIN
web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
BIN
web/icons/Icon-192.png
Normal file
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
BIN
web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
web/icons/Icon-maskable-192.png
Normal file
BIN
web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
web/icons/Icon-maskable-512.png
Normal file
BIN
web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
46
web/index.html
Normal file
46
web/index.html
Normal 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
35
web/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user