mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
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>
114 lines
3.1 KiB
Dart
114 lines
3.1 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:crdt_sync/crdt_sync.dart';
|
|
import 'package:todo/data/note.dart';
|
|
import 'package:todo/data/note_repository.dart';
|
|
|
|
/// In-memory stand-in for [NoteRepository] used by widget tests.
|
|
///
|
|
/// It implements the same public API but backs it with a plain list and a
|
|
/// broadcast [StreamController] — no SQLite, so no pending sqflite timers to
|
|
/// fight the widget tester's fake-async clock. Streams emit synchronously on
|
|
/// every change, making tests fast and deterministic.
|
|
///
|
|
/// It also records the last [NoteSort]/[NoteFilter] passed to [watchNotes],
|
|
/// so list-screen tests can assert the UI built the right query without
|
|
/// re-testing the repository's SQL (covered separately by unit tests).
|
|
class FakeNoteRepository implements NoteRepository {
|
|
FakeNoteRepository([List<Note>? initial]) : _notes = [...?initial];
|
|
|
|
final List<Note> _notes;
|
|
final _controller = StreamController<List<Note>>.broadcast();
|
|
final _changesController = StreamController<void>.broadcast();
|
|
|
|
NoteSort? lastSort;
|
|
NoteFilter? lastFilter;
|
|
|
|
/// Emits the current snapshot to a new subscriber first (so late-binding
|
|
/// [StreamBuilder]s get the seed), then forwards subsequent changes.
|
|
Stream<List<Note>> _snapshots() async* {
|
|
yield List.unmodifiable(_notes);
|
|
yield* _controller.stream;
|
|
}
|
|
|
|
void _emit() {
|
|
if (!_controller.isClosed) _controller.add(List.unmodifiable(_notes));
|
|
if (!_changesController.isClosed) _changesController.add(null);
|
|
}
|
|
|
|
@override
|
|
Stream<void> get changes => _changesController.stream;
|
|
|
|
@override
|
|
Future<void> upsert(Note note) async {
|
|
_notes
|
|
..removeWhere((n) => n.id == note.id)
|
|
..add(note);
|
|
_emit();
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(String id) async {
|
|
_notes.removeWhere((n) => n.id == id);
|
|
_emit();
|
|
}
|
|
|
|
@override
|
|
Future<ImportOutcome> importNotes(List<Note> incoming) async {
|
|
var added = 0;
|
|
var updated = 0;
|
|
var skipped = 0;
|
|
for (final note in incoming) {
|
|
final i = _notes.indexWhere((n) => n.id == note.id);
|
|
if (i < 0) {
|
|
_notes.add(note);
|
|
added++;
|
|
} else if (note.updatedAt.isAfter(_notes[i].updatedAt)) {
|
|
_notes[i] = note;
|
|
updated++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
_emit();
|
|
return ImportOutcome(added: added, updated: updated, skipped: skipped);
|
|
}
|
|
|
|
@override
|
|
Future<List<Note>> listNotes({
|
|
NoteSort sort = NoteSort.modifiedDesc,
|
|
NoteFilter filter = const NoteFilter(),
|
|
}) async => List.unmodifiable(_notes);
|
|
|
|
@override
|
|
Stream<List<Note>> watchNotes({
|
|
NoteSort sort = NoteSort.modifiedDesc,
|
|
NoteFilter filter = const NoteFilter(),
|
|
}) {
|
|
lastSort = sort;
|
|
lastFilter = filter;
|
|
return _snapshots();
|
|
}
|
|
|
|
@override
|
|
Stream<int> watchCount() => _snapshots().map((n) => n.length);
|
|
|
|
@override
|
|
String get nodeId => 'fake-node';
|
|
|
|
@override
|
|
Log exportLog() => <String, Record>{};
|
|
|
|
@override
|
|
Future<void> importLog(Log remote) async {}
|
|
|
|
@override
|
|
Future<void> seedFrom(Log log) async {}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
await _controller.close();
|
|
await _changesController.close();
|
|
}
|
|
}
|