todo-app/lib/sync/sync_service.dart
Krzysztof kuhy Rudnicki 7f84414c87 Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
  and last-updated (AND-combined), a priority filter, and a new status
  filter. Default view hides Done/Abandoned and renders as "unfiltered"
  (no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".

Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
  attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
  Schema migration v2->v3 rewrites legacy priority 0 -> Medium.

Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
  and "Import notes" (file picker + safe newer-wins merge by id).

Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
  estimate/refs scaffold.

Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
  injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
  (note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
  branch excluded via coverage:ignore (unreachable on the Linux test host).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00

94 lines
3.2 KiB
Dart

import 'dart:convert';
import 'package:sqlite_crdt/sqlite_crdt.dart';
import '../data/note_repository.dart';
import 'github_client.dart';
/// Outcome of a sync run, for surfacing in the UI.
class SyncResult {
const SyncResult({required this.mergedDevices, required this.pushed});
/// How many other devices' changesets were pulled and merged.
final int mergedDevices;
/// Whether this device pushed its own changeset.
final bool pushed;
@override
String toString() =>
'SyncResult(mergedDevices: $mergedDevices, pushed: $pushed)';
}
/// Synchronises a [NoteRepository] with a GitHub repo used as dumb storage.
///
/// Design: each device owns exactly one file, `changesets/<nodeId>.json`,
/// holding its full CRDT changeset. Because no two devices ever write the
/// same file, there are **no git-level merge conflicts**. Data convergence
/// is handled entirely by the CRDT layer: pulling every other device's
/// changeset and [NoteRepository.merge]-ing it is commutative and
/// idempotent, so repeated syncs in any order converge to the same state.
class SyncService {
const SyncService({this.changesetDir = 'changesets'});
/// Directory in the repo under which per-device changeset files live.
final String changesetDir;
/// Runs a full pull-merge-push cycle. Safe to call repeatedly.
Future<SyncResult> sync(NoteRepository repo, GitHubClient github) async {
final nodeId = repo.nodeId;
final ownFileName = '$nodeId.json';
// 1. Pull: list all device changeset files, merge everyone else's.
final files = await github.listDirectory(changesetDir);
var merged = 0;
String? ownSha;
for (final file in files) {
if (file.name == ownFileName) {
ownSha = file.sha; // Remember our file's SHA so we can update it.
continue;
}
final text = await github.getFileText(file.path);
if (text == null) continue;
await repo.merge(_decodeChangeset(text));
merged++;
}
// 2. Push: upload our own (now-merged) changeset under our node id.
final changeset = await repo.getChangeset();
await github.putFileText(
'$changesetDir/$ownFileName',
_encodeChangeset(changeset),
sha: ownSha,
message: 'sync: $nodeId @ ${DateTime.now().toUtc().toIso8601String()}',
);
return SyncResult(mergedDevices: merged, pushed: true);
}
/// Serialises a changeset to pretty JSON. All values are already
/// primitives (hlc/modified are stored as TEXT), so no custom encoding
/// is needed.
String _encodeChangeset(CrdtChangeset changeset) =>
const JsonEncoder.withIndent(' ').convert(changeset);
/// Parses a JSON changeset back into the typed shape that
/// [NoteRepository.merge] expects.
///
/// The `hlc` field must be revived from its string form into an [Hlc]
/// object, because `validateChangeset` casts it directly to [Hlc].
CrdtChangeset _decodeChangeset(String text) {
final raw = jsonDecode(text) as Map<String, dynamic>;
return raw.map(
(table, records) => MapEntry(
table,
(records as List).map((r) {
final record = (r as Map).cast<String, Object?>();
record['hlc'] = Hlc.parse(record['hlc'] as String);
return record;
}).toList(),
),
);
}
}