diff --git a/.gitignore b/.gitignore index 3820a95..db5b834 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,10 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Exported personal notes — never commit (contains private idea content) +BACKLOG.md +todo-backlog.md + +# Android Gradle build output +/android/build/ diff --git a/lib/data/note.dart b/lib/data/note.dart index 6db0824..8d9bd76 100644 --- a/lib/data/note.dart +++ b/lib/data/note.dart @@ -8,24 +8,59 @@ library; /// Priority tier for a note, used for sorting and visual grouping. /// -/// Stored as the integer [value] so ordering is trivial in SQL. +/// Every note always has a priority (there is no "none"); [medium] is the +/// default. Stored as the integer [value] so ordering is trivial in SQL. enum Priority { - none(0), - low(1), - medium(2), - high(3); + low(1, 'Low'), + medium(2, 'Medium'), + high(3, 'High'); - const Priority(this.value); + const Priority(this.value, this.label); + + /// The default applied to new notes and to any legacy/unknown value. + static const Priority defaultValue = Priority.medium; /// Integer persisted in the database; higher means more important. final int value; - /// Rebuilds a [Priority] from its stored [value], defaulting to [none] - /// for any unknown/legacy value so reads never throw. + /// Human-readable label for UI controls (pickers, filters, list rows). + final String label; + + /// Rebuilds a [Priority] from its stored [value], defaulting to + /// [defaultValue] for any unknown/legacy value (e.g. the old `0` = none) + /// so reads never throw and pre-existing notes show as Medium. static Priority fromValue(int? value) { return Priority.values.firstWhere( (p) => p.value == value, - orElse: () => Priority.none, + orElse: () => defaultValue, + ); + } +} + +/// Workflow state of a note, independent of its [Priority]. +/// +/// Stored as the integer [value]. [todo] is the default (0) so existing +/// notes created before this field existed read back as "to do". +enum Status { + todo(0, 'To do'), + inProgress(1, 'In progress'), + done(2, 'Done'), + abandoned(3, 'Abandoned'); + + const Status(this.value, this.label); + + /// Integer persisted in the database. + final int value; + + /// Human-readable label for UI controls. + final String label; + + /// Rebuilds a [Status] from its stored [value], defaulting to [todo] + /// for any unknown/legacy value so reads never throw. + static Status fromValue(int? value) { + return Status.values.firstWhere( + (s) => s.value == value, + orElse: () => Status.todo, ); } } @@ -36,6 +71,7 @@ class Note { required this.id, required this.text, required this.priority, + required this.status, required this.createdAt, required this.updatedAt, }); @@ -49,6 +85,9 @@ class Note { /// Priority tier for sorting/filtering. final Priority priority; + /// Workflow state (to do / in progress / done / abandoned). + final Status status; + /// When the note was first created (set once, never changed). final DateTime createdAt; @@ -64,17 +103,24 @@ class Note { id: row['id'] as String, text: (row['text'] as String?) ?? '', priority: Priority.fromValue(row['priority'] as int?), + status: Status.fromValue(row['status'] as int?), createdAt: DateTime.parse(row['created_at'] as String), updatedAt: DateTime.parse(row['updated_at'] as String), ); } /// Returns a copy with selected fields replaced. - Note copyWith({String? text, Priority? priority, DateTime? updatedAt}) { + Note copyWith({ + String? text, + Priority? priority, + Status? status, + DateTime? updatedAt, + }) { return Note( id: id, text: text ?? this.text, priority: priority ?? this.priority, + status: status ?? this.status, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, ); diff --git a/lib/data/note_repository.dart b/lib/data/note_repository.dart index bd7a34b..17e7eee 100644 --- a/lib/data/note_repository.dart +++ b/lib/data/note_repository.dart @@ -3,11 +3,27 @@ import 'package:sqlite_crdt/sqlite_crdt.dart'; import 'note.dart'; /// How the history list should be ordered. -enum NoteSort { - createdDesc, - modifiedDesc, - alphabetical, - priorityDesc, +enum NoteSort { createdDesc, modifiedDesc, alphabetical, priorityDesc } + +/// Summary of an [NoteRepository.importNotes] run, for user feedback. +class ImportOutcome { + const ImportOutcome({ + required this.added, + required this.updated, + required this.skipped, + }); + + /// Notes that did not exist locally and were created. + final int added; + + /// Existing notes overwritten because the import was newer. + final int updated; + + /// Notes skipped because the local copy was the same age or newer. + final int skipped; + + /// Total notes considered in the import. + int get total => added + updated + skipped; } /// Local-first persistence for [Note]s, backed by a CRDT SQLite database. @@ -29,22 +45,9 @@ class NoteRepository { static Future open(String path) async { final crdt = await SqliteCrdt.open( path, - version: 1, - onCreate: (db, version) async { - // Plain columns only; the CRDT layer adds its own bookkeeping - // columns transparently. ISO-8601 strings keep timestamps both - // human-readable and lexicographically sortable. - await db.execute(''' - CREATE TABLE notes ( - id TEXT NOT NULL, - text TEXT NOT NULL DEFAULT '', - priority INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (id) - ) - '''); - }, + version: _schemaVersion, + onCreate: _onCreate, + onUpgrade: _onUpgrade, ); return NoteRepository._(crdt); } @@ -52,23 +55,52 @@ class NoteRepository { /// Opens a transient in-memory database; intended for tests. static Future openInMemory() async { final crdt = await SqliteCrdt.openInMemory( - version: 1, - onCreate: (db, version) async { - await db.execute(''' - CREATE TABLE notes ( - id TEXT NOT NULL, - text TEXT NOT NULL DEFAULT '', - priority INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (id) - ) - '''); - }, + version: _schemaVersion, + onCreate: _onCreate, + onUpgrade: _onUpgrade, ); return NoteRepository._(crdt); } + /// Current schema version. Bump when adding columns and add the matching + /// branch to [_onUpgrade] so existing on-device databases migrate. + static const int _schemaVersion = 3; + + /// Creates the schema for a brand-new database. Plain columns only; the + /// CRDT layer adds its own bookkeeping columns transparently. ISO-8601 + /// strings keep timestamps human-readable and lexicographically sortable. + static Future _onCreate(CrdtTableExecutor db, int version) async { + await db.execute(''' + CREATE TABLE notes ( + id TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 2, + status INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (id) + ) + '''); + } + + /// Migrates an existing database forward one version at a time. + /// + /// - v1 → v2 adds the [Status] column (rows back-fill to `Status.todo`). + /// - v2 → v3 drops the old "none" priority: any legacy `0` becomes + /// `Priority.medium` (2) so every note has a real priority and shows up + /// in priority filters. This runs deterministically on every device, so + /// the back-fill converges without needing a synced changeset. + static Future _onUpgrade(CrdtTableExecutor db, int from, int to) async { + if (from < 2) { + await db.execute( + 'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0', + ); + } + if (from < 3) { + await db.execute('UPDATE notes SET priority = 2 WHERE priority = 0'); + } + } + /// Inserts a new note or updates the existing one with the same [id]. /// /// This is the single write path used by the capture screen's @@ -76,17 +108,19 @@ class NoteRepository { Future upsert(Note note) async { await _crdt.execute( ''' - INSERT INTO notes (id, text, priority, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5) + INSERT INTO notes (id, text, priority, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT (id) DO UPDATE SET text = ?2, priority = ?3, - updated_at = ?5 + status = ?4, + updated_at = ?6 ''', [ note.id, note.text, note.priority.value, + note.status.value, note.createdAt.toIso8601String(), note.updatedAt.toIso8601String(), ], @@ -99,25 +133,71 @@ class NoteRepository { await _crdt.execute('DELETE FROM notes WHERE id = ?1', [id]); } - /// Returns all live notes ordered by [sort]. + /// Merges [incoming] notes (e.g. from an imported file) into local storage. + /// + /// Safe by design — it never destroys a *newer* local edit: an incoming + /// note overwrites the local one only when its [Note.updatedAt] is strictly + /// newer, or when the id is not present locally. This makes re-importing a + /// stale backup a no-op for notes you've since edited, upholding the + /// "never lose ideas" guarantee. Notes absent from [incoming] are kept. + Future importNotes(List incoming) async { + final existing = {for (final n in await listNotes()) n.id: n}; + var added = 0; + var updated = 0; + var skipped = 0; + for (final note in incoming) { + final local = existing[note.id]; + if (local == null) { + await upsert(note); + added++; + } else if (note.updatedAt.isAfter(local.updatedAt)) { + await upsert(note); + updated++; + } else { + skipped++; + } + } + return ImportOutcome(added: added, updated: updated, skipped: skipped); + } + + /// Returns the live notes matching [filter], ordered by [sort]. Future> listNotes({ NoteSort sort = NoteSort.modifiedDesc, + NoteFilter filter = const NoteFilter(), }) async { - final rows = await _crdt - .query('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}'); + final (where, args) = _buildWhere(filter); + final rows = await _crdt.query( + 'SELECT * FROM notes WHERE $where ${_orderBy(sort)}', + args, + ); return rows.map(Note.fromRow).toList(); } - /// Emits the ordered note list and re-emits whenever the table changes, - /// so the UI can stay in sync without manual refreshes. + /// Emits the matching, ordered note list and re-emits whenever the table + /// changes, so the UI can stay in sync without manual refreshes. + /// + /// [watch] takes an args *builder* (`() => args`); the captured [args] + /// list is immutable for this call because [filter] is immutable, so the + /// builder is safe to re-invoke. Stream> watchNotes({ NoteSort sort = NoteSort.modifiedDesc, + NoteFilter filter = const NoteFilter(), }) { + final (where, args) = _buildWhere(filter); return _crdt - .watch('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}') + .watch('SELECT * FROM notes WHERE $where ${_orderBy(sort)}', () => args) .map((rows) => rows.map(Note.fromRow).toList()); } + /// Emits the live count of non-deleted notes. Cheaper than [watchNotes] + /// for a header badge: SQLite computes the count without materialising or + /// parsing any rows, so per-keystroke autosave doesn't churn the UI. + Stream watchCount() { + return _crdt + .watch('SELECT COUNT(*) AS c FROM notes WHERE is_deleted = 0') + .map((rows) => (rows.first['c'] as int?) ?? 0); + } + /// This device's stable CRDT node id. Used to name its changeset file /// in the sync repo so two devices never write the same file. String get nodeId => _crdt.nodeId; @@ -146,4 +226,166 @@ class NoteRepository { return 'ORDER BY priority DESC, updated_at DESC'; } } + + /// Builds the parameterised WHERE clause for [filter]. + /// + /// Returns the clause body (always rooted at `is_deleted = 0` so + /// tombstones stay hidden) and the positional argument list. All user + /// input is bound as parameters — never string-interpolated — so the + /// query is injection-safe. Date bounds use ISO-8601 string comparison, + /// which is valid because the stored timestamps are fixed-width and + /// lexicographically ordered. + (String, List) _buildWhere(NoteFilter filter) { + final clauses = ['is_deleted = 0']; + final args = []; + + final query = filter.query.trim(); + if (query.isNotEmpty) { + // Escape LIKE wildcards in the user's text so a literal '%' or '_' + // matches itself. LIKE is ASCII-case-insensitive by default. + final escaped = query + .replaceAll(r'\', r'\\') + .replaceAll('%', r'\%') + .replaceAll('_', r'\_'); + clauses.add(r"text LIKE ? ESCAPE '\'"); + args.add('%$escaped%'); + } + + if (filter.priorities.isNotEmpty) { + final placeholders = List.filled( + filter.priorities.length, + '?', + ).join(', '); + clauses.add('priority IN ($placeholders)'); + args.addAll(filter.priorities.map((p) => p.value)); + } + + if (filter.statuses.isNotEmpty) { + final placeholders = List.filled(filter.statuses.length, '?').join(', '); + clauses.add('status IN ($placeholders)'); + args.addAll(filter.statuses.map((s) => s.value)); + } + + _addDateBounds( + clauses, + args, + 'created_at', + filter.createdFrom, + filter.createdTo, + ); + _addDateBounds( + clauses, + args, + 'updated_at', + filter.updatedFrom, + filter.updatedTo, + ); + + return (clauses.join(' AND '), args); + } + + /// Appends inclusive day-granularity bounds for [column] to [clauses]. + /// + /// [from]/[to] are treated as whole calendar days: `from` includes its + /// entire day (compared from 00:00:00) and `to` includes its entire day + /// (compared with `< to+1 day`), matching how a user reads a date range. + void _addDateBounds( + List clauses, + List args, + String column, + DateTime? from, + DateTime? to, + ) { + if (from != null) { + clauses.add('$column >= ?'); + args.add(_startOfDay(from).toIso8601String()); + } + if (to != null) { + clauses.add('$column < ?'); + args.add(_startOfDay(to).add(const Duration(days: 1)).toIso8601String()); + } + } + + /// Midnight (local) of [t]'s calendar day. + static DateTime _startOfDay(DateTime t) => DateTime(t.year, t.month, t.day); +} + +/// An immutable set of constraints for querying notes. +/// +/// All fields combine with logical AND. Empty/null fields impose no +/// constraint. Lives in the data layer so the SQL it drives never leaks +/// into the UI. Construct copies with [copyWith] when toggling one facet. +class NoteFilter { + const NoteFilter({ + this.query = '', + this.priorities = const {}, + this.statuses = const {}, + this.createdFrom, + this.createdTo, + this.updatedFrom, + this.updatedTo, + }); + + /// Case-insensitive substring matched against the note body. + final String query; + + /// Notes must have one of these priorities. Empty means "any priority". + final Set priorities; + + /// Notes must have one of these statuses. Empty means "any status". + final Set statuses; + + /// Inclusive lower/upper bounds (by calendar day) on the creation date. + final DateTime? createdFrom; + final DateTime? createdTo; + + /// Inclusive lower/upper bounds (by calendar day) on the last-updated date. + final DateTime? updatedFrom; + final DateTime? updatedTo; + + /// True when no constraint is active (the unfiltered, full list). + bool get isEmpty => + query.trim().isEmpty && + priorities.isEmpty && + statuses.isEmpty && + createdFrom == null && + createdTo == null && + updatedFrom == null && + updatedTo == null; + + /// Number of distinct active facets, for an "N filters" badge in the UI. + int get activeCount { + var n = 0; + if (query.trim().isNotEmpty) n++; + if (priorities.isNotEmpty) n++; + if (statuses.isNotEmpty) n++; + if (createdFrom != null || createdTo != null) n++; + if (updatedFrom != null || updatedTo != null) n++; + return n; + } + + /// Returns a copy with selected facets replaced. A `null` argument keeps + /// the current value; clearing a date is done via the dedicated [clear] + /// flags so `null` can mean "unchanged". + NoteFilter copyWith({ + String? query, + Set? priorities, + Set? statuses, + DateTime? createdFrom, + DateTime? createdTo, + DateTime? updatedFrom, + DateTime? updatedTo, + bool clearCreated = false, + bool clearUpdated = false, + }) { + return NoteFilter( + query: query ?? this.query, + priorities: priorities ?? this.priorities, + statuses: statuses ?? this.statuses, + createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom), + createdTo: clearCreated ? null : (createdTo ?? this.createdTo), + updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom), + updatedTo: clearUpdated ? null : (updatedTo ?? this.updatedTo), + ); + } } diff --git a/lib/main.dart b/lib/main.dart index 79f825f..cb825dc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,6 @@ +// 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'; diff --git a/lib/sync/github_client.dart b/lib/sync/github_client.dart index da141cf..f4ba368 100644 --- a/lib/sync/github_client.dart +++ b/lib/sync/github_client.dart @@ -38,11 +38,11 @@ class GitHubClient { required String token, http.Client? httpClient, this.branch = 'main', - }) // Dart forbids private named params, so this can't be an initializing - // formal; assign it explicitly. - // ignore: prefer_initializing_formals - : _token = token, - _http = httpClient ?? http.Client(); + }) // Dart forbids private named params, so this can't be an initializing + // formal; assign it explicitly. + // ignore: prefer_initializing_formals + : _token = token, + _http = httpClient ?? http.Client(); final String owner; final String repo; @@ -53,11 +53,11 @@ class GitHubClient { static const _apiBase = 'https://api.github.com'; Map get _headers => { - 'Authorization': 'Bearer $_token', - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'todo-app-sync', - }; + 'Authorization': 'Bearer $_token', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'todo-app-sync', + }; Uri _contentsUri(String path) => Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path'); @@ -76,11 +76,13 @@ class GitHubClient { return decoded .cast>() .where((e) => e['type'] == 'file') - .map((e) => GitHubFile( - name: e['name'] as String, - path: e['path'] as String, - sha: e['sha'] as String, - )) + .map( + (e) => GitHubFile( + name: e['name'] as String, + path: e['path'] as String, + sha: e['sha'] as String, + ), + ) .toList(); } @@ -148,7 +150,10 @@ class GitHubClient { void _ensureOk(http.Response res, String action) { if (res.statusCode < 200 || res.statusCode >= 300) { - throw GitHubApiException(res.statusCode, 'Failed to $action: ${res.body}'); + throw GitHubApiException( + res.statusCode, + 'Failed to $action: ${res.body}', + ); } } } diff --git a/lib/sync/github_device_auth.dart b/lib/sync/github_device_auth.dart index cd8a902..d812ad5 100644 --- a/lib/sync/github_device_auth.dart +++ b/lib/sync/github_device_auth.dart @@ -68,9 +68,9 @@ class GitHubDeviceAuth { this.scope = 'repo', http.Client? httpClient, Future Function(Duration)? delay, - }) : _http = httpClient ?? http.Client(), - // Indirection so tests can skip real waiting between polls. - _delay = delay ?? Future.delayed; + }) : _http = httpClient ?? http.Client(), + // Indirection so tests can skip real waiting between polls. + _delay = delay ?? Future.delayed; final String clientId; @@ -82,8 +82,7 @@ class GitHubDeviceAuth { static const _deviceCodeUrl = 'https://github.com/login/device/code'; static const _tokenUrl = 'https://github.com/login/oauth/access_token'; - static const _grantType = - 'urn:ietf:params:oauth:grant-type:device_code'; + static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code'; /// Step 1: ask GitHub for a device + user code. Future requestDeviceCode() async { @@ -96,7 +95,8 @@ class GitHubDeviceAuth { throw DeviceAuthException('http_${res.statusCode}', res.body); } return DeviceCodeResponse.fromJson( - jsonDecode(res.body) as Map); + jsonDecode(res.body) as Map, + ); } /// Step 2: poll until the user authorizes, returning the access token. @@ -132,7 +132,9 @@ class GitHubDeviceAuth { intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5; case final String error: throw DeviceAuthException( - error, (json['error_description'] as String?) ?? error); + error, + (json['error_description'] as String?) ?? error, + ); case null: throw DeviceAuthException('unknown', 'Unexpected response: $json'); } diff --git a/lib/sync/notes_markdown.dart b/lib/sync/notes_markdown.dart new file mode 100644 index 0000000..ea62365 --- /dev/null +++ b/lib/sync/notes_markdown.dart @@ -0,0 +1,103 @@ +import 'package:uuid/uuid.dart'; + +import '../data/note.dart'; + +/// Serialises notes to (and parses them back from) a single Markdown file. +/// +/// The whole document is valid Markdown: each note is preceded by an HTML +/// comment carrying its metadata (id, priority, status, timestamps), which +/// renders invisibly, followed by the note body verbatim. Keeping the `id` +/// lets [NoteRepository.importNotes] re-import a file as a *merge* (by id) +/// rather than creating duplicates — the basis for "never lose ideas" +/// recovery and round-tripping a backup. +class NotesMarkdown { + const NotesMarkdown._(); + + static const _uuid = Uuid(); + + /// First line of an exported file; identifies the format/version. + static const header = ''; + + /// Matches a per-note metadata marker at the start of a line. The body is + /// everything between one marker and the next (or end of file). + static final _markerPattern = RegExp( + r'^[ \t]*$', + multiLine: true, + ); + + /// Matches `key="value"` attribute pairs inside a marker. + static final _attrPattern = RegExp(r'(\w+)="([^"]*)"'); + + /// Renders [notes] to a single Markdown document. + static String export(List notes) { + final buffer = StringBuffer() + ..writeln(header) + ..writeln(); + for (final note in notes) { + buffer + ..writeln( + '', + ) + ..writeln(note.text) + ..writeln(); + } + return buffer.toString(); + } + + /// Parses a previously exported (or hand-written) document back into notes. + /// + /// Tolerant by design: a missing/blank `id` gets a fresh UUID (treated as + /// a new note), and unknown/missing priority/status/timestamps fall back + /// to sensible defaults so a partially hand-edited file never throws. + static List parse(String content) { + final markers = _markerPattern.allMatches(content).toList(); + final notes = []; + for (var i = 0; i < markers.length; i++) { + final marker = markers[i]; + final attrs = _parseAttrs(marker.group(1) ?? ''); + final bodyStart = marker.end; + final bodyEnd = i + 1 < markers.length + ? markers[i + 1].start + : content.length; + final body = content.substring(bodyStart, bodyEnd).trim(); + + final id = attrs['id']; + notes.add( + Note( + id: (id != null && id.isNotEmpty) ? id : _uuid.v4(), + text: body, + priority: _enumByName( + Priority.values, + attrs['priority'], + Priority.defaultValue, + ), + status: _enumByName(Status.values, attrs['status'], Status.todo), + createdAt: + DateTime.tryParse(attrs['created'] ?? '') ?? DateTime.now(), + updatedAt: + DateTime.tryParse(attrs['updated'] ?? '') ?? DateTime.now(), + ), + ); + } + return notes; + } + + /// Extracts `key="value"` pairs from a marker's attribute string. + static Map _parseAttrs(String raw) { + return { + for (final m in _attrPattern.allMatches(raw)) m.group(1)!: m.group(2)!, + }; + } + + /// Resolves an enum value by its [Enum.name], falling back to [fallback]. + static T _enumByName( + List values, + String? name, + T fallback, + ) { + return values.firstWhere((v) => v.name == name, orElse: () => fallback); + } +} diff --git a/lib/sync/sync_service.dart b/lib/sync/sync_service.dart index e977569..fb0e5ba 100644 --- a/lib/sync/sync_service.dart +++ b/lib/sync/sync_service.dart @@ -7,10 +7,7 @@ import 'github_client.dart'; /// Outcome of a sync run, for surfacing in the UI. class SyncResult { - const SyncResult({ - required this.mergedDevices, - required this.pushed, - }); + const SyncResult({required this.mergedDevices, required this.pushed}); /// How many other devices' changesets were pulled and merged. final int mergedDevices; diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 47b084b..17fb841 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -28,6 +28,29 @@ class CaptureScreen extends StatefulWidget { class _CaptureScreenState extends State { static const _uuid = Uuid(); + /// Placeholder for the note's title line; selected on reset so the first + /// keystroke replaces it. + static const _titlePlaceholder = ''; + + /// The structured scaffold pre-filled into every new note (see the + /// `` format). Pre-filling beats a hint because the em-dashes + /// and labels are tedious to type on mobile — the user just fills the gaps. + static const _template = + '$_titlePlaceholder\n' + '\n' + 'what — \n' + 'where — \n' + 'must —\n' + '- \n' + 'nice —\n' + '- \n' + 'out —\n' + '- \n' + 'done — \n' + 'depends — \n' + 'estimate — \n' + 'refs — '; + final TextEditingController _controller = TextEditingController(); final FocusNode _focusNode = FocusNode(); @@ -37,6 +60,11 @@ class _CaptureScreenState extends State { DateTime? _draftCreatedAt; DateTime? _lastSavedAt; + /// Priority/status applied to the current draft. Chosen before or during + /// typing; persisted on the first keystroke and on every later change. + Priority _draftPriority = Priority.defaultValue; + Status _draftStatus = Status.todo; + final SyncService _syncService = const SyncService(); SyncSettings? _settings; bool _syncing = false; @@ -44,11 +72,29 @@ class _CaptureScreenState extends State { @override void initState() { super.initState(); + _resetToTemplate(); SyncSettings.load().then((s) { if (mounted) setState(() => _settings = s); }); } + /// Loads the blank template into the field with the title placeholder + /// selected, so typing immediately overwrites it. Setting the controller + /// value programmatically does not fire [_onChanged], so this never + /// persists a note on its own — only a real edit does. + void _resetToTemplate() { + _controller.value = const TextEditingValue( + text: _template, + selection: TextSelection( + baseOffset: 0, + extentOffset: _titlePlaceholder.length, + ), + ); + } + + /// Whether [text] is still the untouched scaffold (nothing worth saving). + bool _isPristine(String text) => text.trim() == _template.trim(); + @override void dispose() { _controller.dispose(); @@ -62,7 +108,8 @@ class _CaptureScreenState extends State { if (!mounted) return; final result = await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => SettingsScreen(initial: current), + builder: (_) => + SettingsScreen(initial: current, repository: widget.repository), ), ); if (result != null && mounted) setState(() => _settings = result); @@ -112,7 +159,9 @@ class _CaptureScreenState extends State { /// the first non-empty keystroke so empty drafts never hit storage. Future _onChanged(String text) async { if (_draftId == null) { - if (text.isEmpty) return; + // Don't persist an empty field or the untouched template scaffold — + // a note is only created once the user actually fills something in. + if (text.isEmpty || _isPristine(text)) return; _draftId = _uuid.v4(); _draftCreatedAt = DateTime.now(); } @@ -121,7 +170,8 @@ class _CaptureScreenState extends State { Note( id: _draftId!, text: text, - priority: Priority.none, + priority: _draftPriority, + status: _draftStatus, createdAt: _draftCreatedAt!, updatedAt: now, ), @@ -129,17 +179,51 @@ class _CaptureScreenState extends State { if (mounted) setState(() => _lastSavedAt = now); } - /// Finalises the current idea and resets the field for the next one. + /// Applies a new priority to the draft, persisting immediately if a note + /// row already exists (otherwise it is applied on the first keystroke). + Future _setPriority(Priority priority) async { + setState(() => _draftPriority = priority); + await _persistDraftMeta(); + } + + /// Applies a new status to the draft, persisting immediately if a note + /// row already exists. + Future _setStatus(Status status) async { + setState(() => _draftStatus = status); + await _persistDraftMeta(); + } + + /// Re-saves the draft's metadata when only priority/status changed. + Future _persistDraftMeta() async { + if (_draftId == null) return; + final now = DateTime.now(); + await widget.repository.upsert( + Note( + id: _draftId!, + text: _controller.text, + priority: _draftPriority, + status: _draftStatus, + createdAt: _draftCreatedAt!, + updatedAt: now, + ), + ); + if (mounted) setState(() => _lastSavedAt = now); + } + + /// Finalises the current idea and resets the field to a fresh template. void _saveAndReset() { - final hadText = _controller.text.trim().isNotEmpty; + // A note was actually persisted only if a draft row was created. + final saved = _draftId != null; setState(() { - _controller.clear(); + _resetToTemplate(); _draftId = null; _draftCreatedAt = null; _lastSavedAt = null; + _draftPriority = Priority.defaultValue; + _draftStatus = Status.todo; }); _focusNode.requestFocus(); - if (hadText) { + if (saved) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Idea saved locally'), @@ -157,10 +241,10 @@ class _CaptureScreenState extends State { title: const Text('Capture'), actions: [ // Live count of stored notes, proving local persistence. - StreamBuilder>( - stream: widget.repository.watchNotes(), + StreamBuilder( + stream: widget.repository.watchCount(), builder: (context, snapshot) { - final count = snapshot.data?.length ?? 0; + final count = snapshot.data ?? 0; return Padding( padding: const EdgeInsets.only(right: 4), child: Center(child: Text('$count saved')), @@ -195,6 +279,32 @@ class _CaptureScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Pickers sit above the editor so the bottom-right Save FAB + // never overlaps them. + Row( + children: [ + Expanded( + child: _MetaDropdown( + label: 'Priority', + value: _draftPriority, + values: Priority.values, + labelOf: (p) => p.label, + onChanged: _setPriority, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetaDropdown( + label: 'Status', + value: _draftStatus, + values: Status.values, + labelOf: (s) => s.label, + onChanged: _setStatus, + ), + ), + ], + ), + const SizedBox(height: 12), Expanded( child: TextField( controller: _controller, @@ -213,11 +323,15 @@ class _CaptureScreenState extends State { ), ), const SizedBox(height: 8), - Text( - _lastSavedAt == null - ? 'Autosaves as you type' - : 'Saved locally at ${_formatTime(_lastSavedAt!)}', - style: theme.textTheme.bodySmall, + // Leave room so the Save FAB doesn't cover the save indicator. + Padding( + padding: const EdgeInsets.only(right: 96), + child: Text( + _lastSavedAt == null + ? 'Autosaves as you type' + : 'Saved locally at ${_formatTime(_lastSavedAt!)}', + style: theme.textTheme.bodySmall, + ), ), ], ), @@ -236,3 +350,49 @@ class _CaptureScreenState extends State { return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; } } + +/// A compact labelled dropdown for picking an enum value (priority/status). +/// +/// Generic over the enum type [T] so the same control drives both pickers +/// without duplication; [labelOf] maps a value to its display string. +class _MetaDropdown extends StatelessWidget { + const _MetaDropdown({ + required this.label, + required this.value, + required this.values, + required this.labelOf, + required this.onChanged, + }); + + final String label; + final T value; + final List values; + final String Function(T) labelOf; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return InputDecorator( + decoration: InputDecoration( + labelText: label, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isDense: true, + isExpanded: true, + items: [ + for (final v in values) + DropdownMenuItem(value: v, child: Text(labelOf(v))), + ], + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ), + ); + } +} diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index 54d60ad..9c01acc 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -1,50 +1,263 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../data/note.dart'; import '../data/note_repository.dart'; -/// Barebones list of all stored/synced notes, newest-modified first. +/// The default status selection: hide completed/dropped work. This is the +/// app's notion of "unfiltered", so it does not count towards the filter +/// badge and is what "Clear all" resets to. +const Set kDefaultStatuses = {Status.todo, Status.inProgress}; + +/// Searchable, filterable, sortable list of stored/synced notes. /// -/// Deliberately minimal for now (the rich history view with filter/sort by -/// created/modified/alphabetical/priority is deferred). Its job today is to -/// show that synced items actually landed locally. -class NotesListScreen extends StatelessWidget { +/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen +/// only owns transient view state ([NoteSort] + [NoteFilter]) and rebuilds +/// the watch stream when that state changes. The stream is memoised so a +/// rebuild (e.g. a search keystroke) does not churn a new DB subscription. +class NotesListScreen extends StatefulWidget { const NotesListScreen({required this.repository, super.key}); final NoteRepository repository; @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Notes')), - body: StreamBuilder>( - stream: repository.watchNotes(), - builder: (context, snapshot) { - final notes = snapshot.data ?? const []; - if (notes.isEmpty) { - return const Center(child: Text('No notes yet')); - } - return ListView.separated( - itemCount: notes.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, i) { - final note = notes[i]; - final firstLine = note.text.split('\n').first; - return ListTile( - title: Text( - firstLine.isEmpty ? '(empty)' : firstLine, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text('edited ${_relative(note.updatedAt)}'), - ); - }, - ); + State createState() => _NotesListScreenState(); +} + +class _NotesListScreenState extends State { + /// How long to wait after the last keystroke before re-querying, so we + /// don't spin up a new subscription on every character typed. + static const _searchDebounce = Duration(milliseconds: 250); + + final TextEditingController _searchController = TextEditingController(); + Timer? _debounce; + + NoteSort _sort = NoteSort.modifiedDesc; + + /// Default view hides completed/dropped work: only To do + In progress. + /// The user can widen this (or clear it) via the filter sheet. + NoteFilter _filter = const NoteFilter(statuses: kDefaultStatuses); + + /// Whether [statuses] is exactly the default selection (so the badge can + /// treat the default view as "unfiltered"). + static bool _statusesAreDefault(Set statuses) => + statuses.length == kDefaultStatuses.length && + statuses.containsAll(kDefaultStatuses); + + /// Number of *user-applied* filter facets, for the badge. Excludes the + /// default status selection so a fresh list shows no badge. + int get _badgeCount { + var count = _filter.activeCount; + if (_statusesAreDefault(_filter.statuses)) count -= 1; + return count; + } + + /// Memoised stream; only replaced when [_sort]/[_filter] actually change. + late Stream> _stream; + + @override + void initState() { + super.initState(); + _stream = widget.repository.watchNotes(sort: _sort, filter: _filter); + } + + @override + void dispose() { + _debounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + /// Rebuilds the watch stream for the current sort + filter. Call only + /// from handlers that change those, never from [build]. + void _applyState() { + setState(() { + _stream = widget.repository.watchNotes(sort: _sort, filter: _filter); + }); + } + + void _onSearchChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(_searchDebounce, () { + _filter = _filter.copyWith(query: value); + _applyState(); + }); + } + + void _setSort(NoteSort sort) { + if (sort == _sort) return; + _sort = sort; + _applyState(); + } + + /// Opens the filter sheet and adopts the edited filter (text query is + /// owned by the search box, so it is preserved across the round-trip). + Future _openFilters() async { + final edited = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => _FilterSheet(initial: _filter), + ); + if (edited != null) { + _filter = edited.copyWith(query: _searchController.text); + _applyState(); + } + } + + /// Opens the per-note actions sheet (priority, status, delete). + Future _openNoteActions(Note note) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => _NoteActionsSheet( + note: note, + onChanged: (updated) async { + await widget.repository.upsert(updated); + }, + onDelete: () async { + await widget.repository.delete(note.id); }, ), ); } + @override + Widget build(BuildContext context) { + final badgeCount = _badgeCount; + return Scaffold( + appBar: AppBar( + title: const Text('Notes'), + actions: [ + PopupMenuButton( + tooltip: 'Sort', + icon: const Icon(Icons.sort), + initialValue: _sort, + onSelected: _setSort, + itemBuilder: (_) => const [ + PopupMenuItem( + value: NoteSort.modifiedDesc, + child: Text('Last updated'), + ), + PopupMenuItem( + value: NoteSort.createdDesc, + child: Text('Newest created'), + ), + PopupMenuItem( + value: NoteSort.alphabetical, + child: Text('Alphabetical'), + ), + PopupMenuItem( + value: NoteSort.priorityDesc, + child: Text('Priority'), + ), + ], + ), + // Filter icon with a badge counting user-applied facets. The + // trailing padding + inward offset keep the badge from being + // clipped at the screen edge. + Padding( + padding: const EdgeInsets.only(right: 8), + child: Badge( + isLabelVisible: badgeCount > 0, + label: Text('$badgeCount'), + offset: const Offset(-8, 4), + child: IconButton( + tooltip: 'Filter', + icon: const Icon(Icons.filter_list), + onPressed: _openFilters, + ), + ), + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: TextField( + controller: _searchController, + onChanged: _onSearchChanged, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search notes…', + prefixIcon: const Icon(Icons.search), + isDense: true, + border: const OutlineInputBorder(), + suffixIcon: _searchController.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + setState(() {}); + }, + ), + ), + ), + ), + Expanded( + child: StreamBuilder>( + stream: _stream, + builder: (context, snapshot) { + final notes = snapshot.data ?? const []; + if (notes.isEmpty) { + return Center( + child: Text( + _filter.isEmpty + ? 'No notes yet' + : 'No notes match these filters', + ), + ); + } + return ListView.separated( + itemCount: notes.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, i) => _NoteTile( + note: notes[i], + onTap: () => _openNoteActions(notes[i]), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// One row in the notes list: first line, then a metadata subtitle. +class _NoteTile extends StatelessWidget { + const _NoteTile({required this.note, required this.onTap}); + + final Note note; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final firstLine = note.text.split('\n').first; + // Every note has a status and a priority now, so both are always shown. + final meta = [ + note.status.label, + note.priority.label, + 'edited ${_relative(note.updatedAt)}', + ].join(' · '); + return ListTile( + title: Text( + firstLine.isEmpty ? '(empty)' : firstLine, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(meta), + trailing: const Icon(Icons.more_vert), + onTap: onTap, + ); + } + /// Compact relative time like "2m ago" for the list subtitle. String _relative(DateTime t) { final d = DateTime.now().difference(t); @@ -54,3 +267,389 @@ class NotesListScreen extends StatelessWidget { return '${d.inDays}d ago'; } } + +/// Bottom sheet for editing one note's priority/status or deleting it. +class _NoteActionsSheet extends StatefulWidget { + const _NoteActionsSheet({ + required this.note, + required this.onChanged, + required this.onDelete, + }); + + final Note note; + final Future Function(Note) onChanged; + final Future Function() onDelete; + + @override + State<_NoteActionsSheet> createState() => _NoteActionsSheetState(); +} + +class _NoteActionsSheetState extends State<_NoteActionsSheet> { + late Priority _priority = widget.note.priority; + late Status _status = widget.note.status; + + Future _persist() async { + await widget.onChanged( + widget.note.copyWith( + priority: _priority, + status: _status, + updatedAt: DateTime.now(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final firstLine = widget.note.text.split('\n').first; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + firstLine.isEmpty ? '(empty)' : firstLine, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 16), + _EnumChips( + label: 'Status', + values: Status.values, + selected: {_status}, + labelOf: (s) => s.label, + onSelected: (s) { + setState(() => _status = s); + _persist(); + }, + ), + const SizedBox(height: 12), + _EnumChips( + label: 'Priority', + values: Priority.values, + selected: {_priority}, + labelOf: (p) => p.label, + onSelected: (p) { + setState(() => _priority = p); + _persist(); + }, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + icon: const Icon(Icons.delete_outline), + label: const Text('Delete note'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: () async { + await widget.onDelete(); + if (context.mounted) Navigator.of(context).pop(); + }, + ), + ], + ), + ), + ); + } +} + +/// Single-select chip row for an enum (used for per-note priority/status). +class _EnumChips extends StatelessWidget { + const _EnumChips({ + required this.label, + required this.values, + required this.selected, + required this.labelOf, + required this.onSelected, + }); + + final String label; + final List values; + final Set selected; + final String Function(T) labelOf; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + for (final v in values) + ChoiceChip( + label: Text(labelOf(v)), + selected: selected.contains(v), + onSelected: (_) => onSelected(v), + ), + ], + ), + ], + ); + } +} + +/// The filter editing sheet: priority + status multi-select and Created / +/// Last-updated date ranges (presets + a custom range picker). Edits a +/// working copy and returns it via [Navigator.pop] on "Apply". +class _FilterSheet extends StatefulWidget { + const _FilterSheet({required this.initial}); + + final NoteFilter initial; + + @override + State<_FilterSheet> createState() => _FilterSheetState(); +} + +class _FilterSheetState extends State<_FilterSheet> { + late Set _priorities = {...widget.initial.priorities}; + late Set _statuses = {...widget.initial.statuses}; + late DateTime? _createdFrom = widget.initial.createdFrom; + late DateTime? _createdTo = widget.initial.createdTo; + late DateTime? _updatedFrom = widget.initial.updatedFrom; + late DateTime? _updatedTo = widget.initial.updatedTo; + + void _toggle(Set set, T value) { + setState(() => set.contains(value) ? set.remove(value) : set.add(value)); + } + + void _clearAll() { + setState(() { + _priorities = {}; + // Reset to the default view (hide Done/Abandoned), not an empty set, + // so "Clear all" matches the app's unfiltered baseline. + _statuses = {...kDefaultStatuses}; + _createdFrom = null; + _createdTo = null; + _updatedFrom = null; + _updatedTo = null; + }); + } + + NoteFilter _build() { + // query is owned by the search box and re-applied by the caller. + return NoteFilter( + priorities: _priorities, + statuses: _statuses, + createdFrom: _createdFrom, + createdTo: _createdTo, + updatedFrom: _updatedFrom, + updatedTo: _updatedTo, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Filters', style: theme.textTheme.titleLarge), + const Spacer(), + TextButton( + onPressed: _clearAll, + child: const Text('Clear all'), + ), + ], + ), + const SizedBox(height: 8), + _MultiChips( + label: 'Status', + values: Status.values, + selected: _statuses, + labelOf: (s) => s.label, + onToggle: (s) => _toggle(_statuses, s), + ), + const SizedBox(height: 12), + _MultiChips( + label: 'Priority', + values: Priority.values, + selected: _priorities, + labelOf: (p) => p.label, + onToggle: (p) => _toggle(_priorities, p), + ), + const SizedBox(height: 12), + _DateRangeField( + label: 'Created', + from: _createdFrom, + to: _createdTo, + onChanged: (from, to) => setState(() { + _createdFrom = from; + _createdTo = to; + }), + ), + const SizedBox(height: 12), + _DateRangeField( + label: 'Last updated', + from: _updatedFrom, + to: _updatedTo, + onChanged: (from, to) => setState(() { + _updatedFrom = from; + _updatedTo = to; + }), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => Navigator.of(context).pop(_build()), + child: const Text('Apply'), + ), + ], + ), + ), + ), + ); + } +} + +/// Multi-select chip group for an enum (used by the filter sheet). +class _MultiChips extends StatelessWidget { + const _MultiChips({ + required this.label, + required this.values, + required this.selected, + required this.labelOf, + required this.onToggle, + }); + + final String label; + final List values; + final Set selected; + final String Function(T) labelOf; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + for (final v in values) + FilterChip( + label: Text(labelOf(v)), + selected: selected.contains(v), + onSelected: (_) => onToggle(v), + ), + ], + ), + ], + ); + } +} + +/// A date-range control offering quick presets plus a custom range picker. +/// +/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to +/// the parent; `null`/`null` means "any date" for this field. +class _DateRangeField extends StatelessWidget { + const _DateRangeField({ + required this.label, + required this.from, + required this.to, + required this.onChanged, + }); + + final String label; + final DateTime? from; + final DateTime? to; + + /// Called with the new (from, to); either may be null to clear. + final void Function(DateTime? from, DateTime? to) onChanged; + + bool get _hasRange => from != null || to != null; + + /// Sets a preset range of the last [days] days ending today. + void _applyDays(int days) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + onChanged(today.subtract(Duration(days: days - 1)), today); + } + + Future _pickCustom(BuildContext context) async { + final now = DateTime.now(); + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime(now.year + 1, 12, 31), + initialDateRange: (from != null && to != null) + ? DateTimeRange(start: from!, end: to!) + : null, + ); + if (picked != null) onChanged(picked.start, picked.end); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(label, style: theme.textTheme.labelLarge), + const Spacer(), + if (_hasRange) + Text(_rangeLabel(), style: theme.textTheme.bodySmall), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + children: [ + ActionChip( + label: const Text('Today'), + onPressed: () => _applyDays(1), + ), + ActionChip( + label: const Text('7 days'), + onPressed: () => _applyDays(7), + ), + ActionChip( + label: const Text('30 days'), + onPressed: () => _applyDays(30), + ), + ActionChip( + label: const Text('Custom…'), + onPressed: () => _pickCustom(context), + ), + if (_hasRange) + ActionChip( + avatar: const Icon(Icons.clear, size: 16), + label: const Text('Any'), + onPressed: () => onChanged(null, null), + ), + ], + ), + ], + ); + } + + /// Compact "YYYY-MM-DD → YYYY-MM-DD" (or one-sided) summary of the range. + String _rangeLabel() { + String d(DateTime? t) => + t == null ? '…' : '${t.year}-${_two(t.month)}-${_two(t.day)}'; + return '${d(from)} → ${d(to)}'; + } + + String _two(int n) => n.toString().padLeft(2, '0'); +} diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 73f6542..c7ed1ff 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -1,34 +1,57 @@ +import 'dart:io'; + +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:url_launcher/url_launcher.dart'; +import '../data/note_repository.dart'; import '../sync/github_client.dart'; import '../sync/github_device_auth.dart'; +import '../sync/notes_markdown.dart'; import '../sync/sync_settings.dart'; -/// Settings screen for GitHub sync configuration. +/// Settings screen for GitHub sync configuration and note backup. /// -/// Primary path: the "Connect GitHub" button runs the OAuth **device flow** -/// (authorize in a browser, no token pasting). The manual token field -/// remains as a fallback. +/// Primary sync path: the "Connect GitHub" button runs the OAuth **device +/// flow** (authorize in a browser, no token pasting). The manual token field +/// remains as a fallback. The Backup section exports/imports all notes as a +/// single Markdown file (see [NotesMarkdown]). class SettingsScreen extends StatefulWidget { - const SettingsScreen({required this.initial, super.key}); + const SettingsScreen({ + required this.initial, + required this.repository, + this.httpClient, + super.key, + }); final SyncSettings initial; + final NoteRepository repository; + + /// Optional HTTP client for the GitHub calls (test-connection and device + /// flow). Injected by tests; production uses each client's default. + final http.Client? httpClient; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { - late final TextEditingController _owner = - TextEditingController(text: widget.initial.owner); - late final TextEditingController _repo = - TextEditingController(text: widget.initial.repo); - late final TextEditingController _token = - TextEditingController(text: widget.initial.token); - late final TextEditingController _clientId = - TextEditingController(text: widget.initial.clientId); + late final TextEditingController _owner = TextEditingController( + text: widget.initial.owner, + ); + late final TextEditingController _repo = TextEditingController( + text: widget.initial.repo, + ); + late final TextEditingController _token = TextEditingController( + text: widget.initial.token, + ); + late final TextEditingController _clientId = TextEditingController( + text: widget.initial.clientId, + ); bool _testing = false; String? _status; @@ -43,11 +66,11 @@ class _SettingsScreenState extends State { } SyncSettings get _current => SyncSettings( - owner: _owner.text.trim(), - repo: _repo.text.trim(), - token: _token.text.trim(), - clientId: _clientId.text.trim(), - ); + owner: _owner.text.trim(), + repo: _repo.text.trim(), + token: _token.text.trim(), + clientId: _clientId.text.trim(), + ); /// Runs the OAuth device flow and, on success, fills in the token field. Future _connectGitHub() async { @@ -56,7 +79,10 @@ class _SettingsScreenState extends State { setState(() => _status = 'Enter the OAuth App client id first.'); return; } - final auth = GitHubDeviceAuth(clientId: clientId); + final auth = GitHubDeviceAuth( + clientId: clientId, + httpClient: widget.httpClient, + ); try { final device = await auth.requestDeviceCode(); if (!mounted) return; @@ -85,12 +111,19 @@ class _SettingsScreenState extends State { _status = null; }); final s = _current; - final client = GitHubClient(owner: s.owner, repo: s.repo, token: s.token); + final client = GitHubClient( + owner: s.owner, + repo: s.repo, + token: s.token, + httpClient: widget.httpClient, + ); try { final ok = await client.canAccessRepo(); - setState(() => _status = ok - ? 'Connected — repo is reachable.' - : 'Could not access ${s.owner}/${s.repo}. Check token scope.'); + setState( + () => _status = ok + ? 'Connected — repo is reachable.' + : 'Could not access ${s.owner}/${s.repo}. Check token scope.', + ); } catch (e) { setState(() => _status = 'Error: $e'); } finally { @@ -105,6 +138,76 @@ class _SettingsScreenState extends State { if (mounted) Navigator.of(context).pop(s); } + /// Exports every note to a single Markdown file. On mobile this opens the + /// system share sheet; on desktop it writes the canonical `~/todo/ + /// BACKLOG.md` so a future tool/agent has a stable path to read. + Future _export() async { + try { + 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}', + ); + } + } + } catch (e) { + if (mounted) setState(() => _status = 'Export failed: $e'); + } + } + + /// Imports notes from a user-picked Markdown file, merging by id so a + /// stale backup never clobbers a newer local edit (see + /// [NoteRepository.importNotes]). + Future _import() async { + try { + const group = XTypeGroup( + label: 'Markdown', + extensions: ['md', 'markdown', 'txt'], + // UTIs/MIME so the picker accepts the file on iOS/Android too. + uniformTypeIdentifiers: ['net.daringfireball.markdown', 'public.text'], + mimeTypes: ['text/markdown', 'text/plain'], + ); + final file = await openFile(acceptedTypeGroups: const [group]); + if (file == null) return; // user cancelled + final content = await file.readAsString(); + + final notes = NotesMarkdown.parse(content); + final outcome = await widget.repository.importNotes(notes); + if (mounted) { + setState( + () => _status = + 'Imported ${outcome.total}: ${outcome.added} new, ' + '${outcome.updated} updated, ${outcome.skipped} unchanged', + ); + } + } catch (e) { + if (mounted) setState(() => _status = 'Import failed: $e'); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -128,8 +231,10 @@ class _SettingsScreenState extends State { ), ), const SizedBox(height: 24), - Text('Connect with GitHub', - style: Theme.of(context).textTheme.titleMedium), + Text( + 'Connect with GitHub', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), TextField( controller: _clientId, @@ -148,8 +253,10 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), const Divider(), const SizedBox(height: 8), - Text('Or paste a token (fallback)', - style: Theme.of(context).textTheme.titleMedium), + Text( + 'Or paste a token (fallback)', + style: Theme.of(context).textTheme.titleMedium, + ), const SizedBox(height: 8), TextField( controller: _token, @@ -182,6 +289,32 @@ class _SettingsScreenState extends State { ), ], ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 8), + Text('Backup', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Export all notes to a single Markdown file, or import/merge a ' + 'file back (matching ids are merged, never duplicated).', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton.icon( + onPressed: _export, + icon: const Icon(Icons.upload_file), + label: const Text('Export notes'), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _import, + icon: const Icon(Icons.download), + label: const Text('Import notes'), + ), + ], + ), if (_status != null) ...[ const SizedBox(height: 16), Text(_status!, style: Theme.of(context).textTheme.bodyMedium), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f6f23bf..7299b5c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index df8d2f7..886932b 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/pubspec.lock b/pubspec.lock index a5a437a..b6284ac 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.3" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -113,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -121,6 +137,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" + url: "https://pub.dev" + source: hosted + version: "0.5.2+8" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: "direct dev" + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -264,6 +344,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" native_toolchain_c: dependency: transitive description: @@ -353,7 +441,7 @@ packages: source: hosted version: "3.1.6" plugin_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" @@ -392,6 +480,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0 + url: "https://pub.dev" + source: hosted + version: "13.1.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" + url: "https://pub.dev" + source: hosted + version: "7.1.0" shared_preferences: dependency: "direct main" description: @@ -614,7 +718,7 @@ packages: source: hosted version: "3.2.5" url_launcher_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" @@ -669,6 +773,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 68b697c..ebbd943 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,8 @@ dependencies: http: ^1.6.0 shared_preferences: ^2.5.5 url_launcher: ^6.3.2 - + share_plus: ^13.1.0 + file_selector: ^1.1.0 dev_dependencies: flutter_test: sdk: flutter @@ -54,6 +55,12 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 + # Plugin platform interfaces, depended on directly so tests can swap in + # fakes for the file picker and URL launcher (see settings_screen_test.dart). + file_selector_platform_interface: ^2.7.0 + url_launcher_platform_interface: ^2.3.2 + plugin_platform_interface: ^2.1.8 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart new file mode 100644 index 0000000..c1eb12d --- /dev/null +++ b/test/capture_screen_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/ui/capture_screen.dart'; + +import 'fake_note_repository.dart'; + +void main() { + // A real CRDT DB schedules sqflite timers that never drain under the + // widget tester's fake clock, so these tests inject a timer-free fake. + // (NOTE: avoid pumpAndSettle — the autofocused field's cursor blink never + // settles; pump explicit frames instead.) + Future pumpCapture(WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + final repo = FakeNoteRepository(); + addTearDown(repo.close); + await tester.pumpWidget(MaterialApp(home: CaptureScreen(repository: repo))); + await tester.pump(); // flush initial stream + settings load + return repo; + } + + testWidgets('pre-fills the structured template', (tester) async { + await pumpCapture(tester); + + expect(find.textContaining(''), findsOneWidget); + expect(find.textContaining('what —'), findsOneWidget); + expect(find.textContaining('done —'), findsOneWidget); + expect(find.text('0 saved'), findsOneWidget); + }); + + testWidgets('saving the untouched template creates no note', (tester) async { + final repo = await pumpCapture(tester); + + await tester.tap(find.text('Save')); + await tester.pump(); + + expect(await repo.listNotes(), isEmpty); + }); + + testWidgets('typing into the template persists a note with defaults', ( + tester, + ) async { + final repo = await pumpCapture(tester); + + await tester.enterText( + find.byType(TextField), + 'My idea\n\nwhat — build the thing', + ); + await tester.pump(); + + final notes = await repo.listNotes(); + expect(notes, hasLength(1)); + expect(notes.single.text, contains('My idea')); + expect(notes.single.priority, Priority.medium); + expect(notes.single.status, Status.todo); + expect(find.text('1 saved'), findsOneWidget); + }); + + testWidgets('save after editing shows a snackbar and resets the template', ( + tester, + ) async { + final repo = await pumpCapture(tester); + + await tester.enterText(find.byType(TextField), 'A real idea'); + await tester.pump(); + await tester.tap(find.text('Save')); + await tester.pump(); // build the snackbar + + expect(find.text('Idea saved locally'), findsOneWidget); + await tester.pump(); + + expect(await repo.listNotes(), hasLength(1)); + expect(find.textContaining(''), findsOneWidget); + }); + + testWidgets('tapping Sync while unconfigured prompts for a token', ( + tester, + ) async { + await pumpCapture(tester); // empty prefs → no token → not configured + + await tester.tap(find.byTooltip('Sync')); + await tester.pump(); // settings load + snackbar + await tester.pump(); + + expect(find.textContaining('Add a GitHub token'), findsOneWidget); + }); + + testWidgets('the notes-list button navigates to the list screen', ( + tester, + ) async { + await pumpCapture(tester); + + await tester.tap(find.byTooltip('Notes')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // route transition + + expect(find.text('Notes'), findsOneWidget); // list screen app bar title + }); + + testWidgets('changing the priority dropdown updates the saved note', ( + tester, + ) async { + final repo = await pumpCapture(tester); + + await tester.enterText(find.byType(TextField), 'Prioritised idea'); + await tester.pump(); + + await tester.tap( + find.byWidgetPredicate((w) => w is DropdownButton), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); // menu open + await tester.tap(find.text('High').last); + await tester.pump(); + + expect((await repo.listNotes()).single.priority, Priority.high); + }); + + testWidgets('changing the status dropdown updates the saved note', ( + tester, + ) async { + final repo = await pumpCapture(tester); + + await tester.enterText(find.byType(TextField), 'Status idea'); + await tester.pump(); + + await tester.tap( + find.byWidgetPredicate((w) => w is DropdownButton), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); // menu open + await tester.tap(find.text('In progress').last); + await tester.pump(); + + expect((await repo.listNotes()).single.status, Status.inProgress); + }); +} diff --git a/test/fake_note_repository.dart b/test/fake_note_repository.dart new file mode 100644 index 0000000..b8e7892 --- /dev/null +++ b/test/fake_note_repository.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:sqlite_crdt/sqlite_crdt.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? initial]) : _notes = [...?initial]; + + final List _notes; + final _controller = StreamController>.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> _snapshots() async* { + yield List.unmodifiable(_notes); + yield* _controller.stream; + } + + void _emit() { + if (!_controller.isClosed) _controller.add(List.unmodifiable(_notes)); + } + + @override + Future upsert(Note note) async { + _notes + ..removeWhere((n) => n.id == note.id) + ..add(note); + _emit(); + } + + @override + Future delete(String id) async { + _notes.removeWhere((n) => n.id == id); + _emit(); + } + + @override + Future importNotes(List 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> listNotes({ + NoteSort sort = NoteSort.modifiedDesc, + NoteFilter filter = const NoteFilter(), + }) async => List.unmodifiable(_notes); + + @override + Stream> watchNotes({ + NoteSort sort = NoteSort.modifiedDesc, + NoteFilter filter = const NoteFilter(), + }) { + lastSort = sort; + lastFilter = filter; + return _snapshots(); + } + + @override + Stream watchCount() => _snapshots().map((n) => n.length); + + @override + String get nodeId => 'fake-node'; + + @override + Future getChangeset() async => {}; + + @override + Future merge(CrdtChangeset changeset) async {} + + @override + Future close() async { + await _controller.close(); + } +} diff --git a/test/github_client_test.dart b/test/github_client_test.dart new file mode 100644 index 0000000..9a641c8 --- /dev/null +++ b/test/github_client_test.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:todo/sync/github_client.dart'; + +void main() { + GitHubClient client(MockClient mock) => + GitHubClient(owner: 'o', repo: 'r', token: 't', httpClient: mock); + + test('listDirectory returns only files and ignores subdirectories', () async { + final mock = MockClient((req) async { + expect(req.headers['Authorization'], contains('t')); + return http.Response( + jsonEncode([ + {'type': 'file', 'name': 'a.json', 'path': 'd/a.json', 'sha': 's1'}, + {'type': 'dir', 'name': 'sub', 'path': 'd/sub', 'sha': 's2'}, + ]), + 200, + ); + }); + final files = await client(mock).listDirectory('d'); + expect(files, hasLength(1)); + expect(files.single.name, 'a.json'); + expect(files.single.sha, 's1'); + }); + + test( + 'listDirectory returns empty on 404 (directory not created yet)', + () async { + final files = await client( + MockClient((_) async => http.Response('', 404)), + ).listDirectory('missing'); + expect(files, isEmpty); + }, + ); + + test('getFileText base64-decodes content; null on 404', () async { + final encoded = base64.encode(utf8.encode('hello world')); + final ok = MockClient( + (_) async => http.Response(jsonEncode({'content': encoded}), 200), + ); + expect(await client(ok).getFileText('f'), 'hello world'); + + final missing = MockClient((_) async => http.Response('', 404)); + expect(await client(missing).getFileText('f'), isNull); + }); + + test( + 'putFileText omits sha when creating, includes it when updating', + () async { + String? sentBody; + final mock = MockClient((req) async { + sentBody = req.body; + return http.Response('{}', 201); + }); + await client(mock).putFileText('f', 'data'); + expect(jsonDecode(sentBody!).containsKey('sha'), isFalse); + + await client(mock).putFileText('f', 'data', sha: 'abc'); + expect(jsonDecode(sentBody!)['sha'], 'abc'); + }, + ); + + test('deleteFile sends the sha', () async { + String? body; + final mock = MockClient((req) async { + body = req.body; + return http.Response('{}', 200); + }); + await client(mock).deleteFile('f', 'sha123'); + expect(jsonDecode(body!)['sha'], 'sha123'); + }); + + test('canAccessRepo reflects the status code', () async { + expect( + await client( + MockClient((_) async => http.Response('{}', 200)), + ).canAccessRepo(), + isTrue, + ); + expect( + await client( + MockClient((_) async => http.Response('', 403)), + ).canAccessRepo(), + isFalse, + ); + }); + + test('throws GitHubApiException on a non-2xx that is not 404', () async { + final mock = MockClient((_) async => http.Response('boom', 500)); + expect( + () => client(mock).getFileText('f'), + throwsA(isA()), + ); + }); +} diff --git a/test/github_device_auth_test.dart b/test/github_device_auth_test.dart index c6619eb..ac5318d 100644 --- a/test/github_device_auth_test.dart +++ b/test/github_device_auth_test.dart @@ -7,10 +7,10 @@ import 'package:todo/sync/github_device_auth.dart'; /// Builds an auth instance whose polls resolve instantly (no real waiting). GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth( - clientId: 'test-client-id', - httpClient: client, - delay: (_) => Future.value(), - ); + clientId: 'test-client-id', + httpClient: client, + delay: (_) => Future.value(), +); const _device = DeviceCodeResponse( deviceCode: 'dev-123', @@ -50,10 +50,15 @@ void main() { calls++; // Pending on the first two polls, then success. if (calls < 3) { - return http.Response(jsonEncode({'error': 'authorization_pending'}), 200); + return http.Response( + jsonEncode({'error': 'authorization_pending'}), + 200, + ); } return http.Response( - jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}), 200); + jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}), + 200, + ); }); final token = await authWith(client).pollForToken(_device); @@ -67,7 +72,9 @@ void main() { calls++; if (calls == 1) { return http.Response( - jsonEncode({'error': 'slow_down', 'interval': 1}), 200); + jsonEncode({'error': 'slow_down', 'interval': 1}), + 200, + ); } return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200); }); @@ -77,13 +84,72 @@ void main() { }); test('pollForToken throws on access_denied', () async { - final client = MockClient((req) async => http.Response( - jsonEncode({'error': 'access_denied', 'error_description': 'no'}), 200)); + final client = MockClient( + (req) async => http.Response( + jsonEncode({'error': 'access_denied', 'error_description': 'no'}), + 200, + ), + ); expect( () => authWith(client).pollForToken(_device), - throwsA(isA() - .having((e) => e.code, 'code', 'access_denied')), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'access_denied', + ), + ), + ); + }); + + test('pollForToken honors slow_down then succeeds', () async { + var calls = 0; + final client = MockClient((req) async { + calls++; + if (calls == 1) { + return http.Response( + jsonEncode({'error': 'slow_down', 'interval': 0}), + 200, + ); + } + return http.Response(jsonEncode({'access_token': 'gho_ok'}), 200); + }); + + expect(await authWith(client).pollForToken(_device), 'gho_ok'); + expect(calls, 2); + }); + + test('pollForToken throws on an unexpected response shape', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'foo': 'bar'}), 200), + ); + expect( + () => authWith(client).pollForToken(_device), + throwsA(isA()), + ); + }); + + test('pollForToken throws when the device code has expired', () async { + final client = MockClient( + (_) async => http.Response(jsonEncode({'access_token': 'x'}), 200), + ); + const expired = DeviceCodeResponse( + deviceCode: 'd', + userCode: 'u', + verificationUri: 'v', + interval: 1, + expiresIn: 0, // deadline is now → loop body never runs + ); + expect( + () => authWith(client).pollForToken(expired), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'expired_token', + ), + ), ); }); } diff --git a/test/note_repository_test.dart b/test/note_repository_test.dart index d0e0486..ae61ce0 100644 --- a/test/note_repository_test.dart +++ b/test/note_repository_test.dart @@ -1,19 +1,30 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite_crdt/sqlite_crdt.dart'; import 'package:todo/data/note.dart'; import 'package:todo/data/note_repository.dart'; void main() { setUpAll(sqfliteFfiInit); - Note note(String id, String text, {Priority priority = Priority.none}) { + Note note( + String id, + String text, { + Priority priority = Priority.medium, + Status status = Status.todo, + DateTime? createdAt, + DateTime? updatedAt, + }) { final now = DateTime.now(); return Note( id: id, text: text, priority: priority, - createdAt: now, - updatedAt: now, + status: status, + createdAt: createdAt ?? now, + updatedAt: updatedAt ?? now, ); } @@ -58,4 +69,306 @@ void main() { expect(notes.first.text, 'high'); expect(notes.last.text, 'low'); }); + + group('text search', () { + test('matches a case-insensitive substring', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + await repo.upsert(note('a', 'Buy MILK and eggs')); + await repo.upsert(note('b', 'call the dentist')); + + final notes = await repo.listNotes( + filter: const NoteFilter(query: 'milk'), + ); + expect(notes.map((n) => n.id), ['a']); + }); + + test('escapes LIKE wildcards so % is matched literally', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + await repo.upsert(note('pct', 'a%b')); + await repo.upsert(note('plain', 'axb')); + + // Without escaping, 'a%b' as a LIKE pattern would also match 'axb'. + final notes = await repo.listNotes( + filter: const NoteFilter(query: 'a%b'), + ); + expect(notes.map((n) => n.id), ['pct']); + }); + }); + + group('attribute filters', () { + test('priority filter includes only the selected priorities', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + await repo.upsert(note('lo', 'l', priority: Priority.low)); + await repo.upsert(note('me', 'm', priority: Priority.medium)); + await repo.upsert(note('hi', 'h', priority: Priority.high)); + + final notes = await repo.listNotes( + filter: const NoteFilter(priorities: {Priority.low, Priority.high}), + ); + expect(notes.map((n) => n.id).toSet(), {'lo', 'hi'}); + }); + + test('status filter includes only the selected statuses', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + await repo.upsert(note('t', 'todo', status: Status.todo)); + await repo.upsert(note('d', 'done', status: Status.done)); + await repo.upsert(note('x', 'gone', status: Status.abandoned)); + + final notes = await repo.listNotes( + filter: const NoteFilter(statuses: {Status.todo, Status.inProgress}), + ); + expect(notes.map((n) => n.id), ['t']); + }); + }); + + group('date range filters', () { + final jan = DateTime(2026, 1, 15, 10); + final jun = DateTime(2026, 6, 15, 10); + + test('created range bounds are inclusive by calendar day', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + await repo.upsert(note('j', 'jan', createdAt: jan, updatedAt: jan)); + await repo.upsert(note('u', 'jun', createdAt: jun, updatedAt: jun)); + + // A single-day range on Jan 15 includes the 10:00 note that day. + final notes = await repo.listNotes( + filter: NoteFilter( + createdFrom: DateTime(2026, 1, 15), + createdTo: DateTime(2026, 1, 15), + ), + ); + expect(notes.map((n) => n.id), ['j']); + }); + + test('created and updated ranges apply independently', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + // Created in Jan, but last updated in Jun. + await repo.upsert( + note('e', 'edited later', createdAt: jan, updatedAt: jun), + ); + + // Matches on the updated range... + final byUpdated = await repo.listNotes( + filter: NoteFilter( + updatedFrom: DateTime(2026, 6, 1), + updatedTo: DateTime(2026, 6, 30), + ), + ); + expect(byUpdated.map((n) => n.id), ['e']); + + // ...but not when the created range excludes January. + final byCreated = await repo.listNotes( + filter: NoteFilter( + createdFrom: DateTime(2026, 6, 1), + createdTo: DateTime(2026, 6, 30), + ), + ); + expect(byCreated, isEmpty); + }); + }); + + test('filters combine with AND', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + await repo.upsert( + note( + 'match', + 'urgent report', + priority: Priority.high, + status: Status.inProgress, + ), + ); + await repo.upsert( + note( + 'wrongPrio', + 'urgent report', + priority: Priority.low, + status: Status.inProgress, + ), + ); + await repo.upsert( + note( + 'wrongText', + 'casual note', + priority: Priority.high, + status: Status.inProgress, + ), + ); + + final notes = await repo.listNotes( + filter: const NoteFilter( + query: 'urgent', + priorities: {Priority.high}, + statuses: {Status.inProgress}, + ), + ); + expect(notes.map((n) => n.id), ['match']); + }); + + group('priority defaults', () { + test('fromValue maps legacy/unknown values to medium', () { + expect(Priority.fromValue(0), Priority.medium); // old "none" + expect(Priority.fromValue(null), Priority.medium); + expect(Priority.fromValue(99), Priority.medium); + // Known values still round-trip. + expect(Priority.fromValue(1), Priority.low); + expect(Priority.fromValue(2), Priority.medium); + expect(Priority.fromValue(3), Priority.high); + }); + }); + + group('importNotes (safe merge)', () { + test('adds notes whose id is not present locally', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + await repo.upsert(note('a', 'local')); + + final outcome = await repo.importNotes([note('b', 'incoming')]); + + expect(outcome.added, 1); + expect(outcome.updated, 0); + expect(outcome.skipped, 0); + expect((await repo.listNotes()).map((n) => n.id).toSet(), {'a', 'b'}); + }); + + test('overwrites a local note only when the import is newer', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + final old = DateTime(2026, 1, 1); + final newer = DateTime(2026, 6, 1); + await repo.upsert(note('a', 'local-old', updatedAt: old)); + + final outcome = await repo.importNotes([ + note('a', 'imported-new', updatedAt: newer), + ]); + + expect(outcome.updated, 1); + final stored = (await repo.listNotes()).single; + expect(stored.text, 'imported-new'); + }); + + test('never clobbers a newer local edit with a stale import', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + final stale = DateTime(2026, 1, 1); + final fresh = DateTime(2026, 6, 1); + // Local note is the freshly-edited one. + await repo.upsert(note('a', 'local-fresh', updatedAt: fresh)); + + final outcome = await repo.importNotes([ + note('a', 'backup-stale', updatedAt: stale), + ]); + + expect(outcome.skipped, 1); + expect(outcome.updated, 0); + // The newer local edit survives — "never lose ideas". + expect((await repo.listNotes()).single.text, 'local-fresh'); + }); + }); + + test('v2→v3 migration backfills priority 0 to medium', () async { + final dir = await Directory.systemTemp.createTemp('todo_migration'); + final path = '${dir.path}/notes.db'; + addTearDown(() => dir.delete(recursive: true)); + + // Build a v2 database (status column present, no priority backfill) and + // insert a legacy note with the old priority 0 ("none"). + final v2 = await SqliteCrdt.open( + path, + version: 2, + onCreate: (db, version) async { + await db.execute(''' + CREATE TABLE notes ( + id TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (id) + ) + '''); + }, + ); + final now = DateTime.now().toIso8601String(); + await v2.execute( + 'INSERT INTO notes (id, text, priority, status, created_at, updated_at) ' + 'VALUES (?1, ?2, ?3, ?4, ?5, ?6)', + ['legacy', 'old idea', 0, 0, now, now], + ); + await v2.close(); + + // Reopening through the repository runs onUpgrade to v3. + final repo = await NoteRepository.open(path); + addTearDown(repo.close); + final notes = await repo.listNotes(); + + expect(notes.single.id, 'legacy'); + expect(notes.single.priority, Priority.medium); + }); + + group('sorting and streams', () { + test('createdDesc and alphabetical orderings', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + final t1 = DateTime(2026, 1, 1); + final t2 = DateTime(2026, 2, 1); + await repo.upsert(note('a', 'banana', createdAt: t1, updatedAt: t1)); + await repo.upsert(note('b', 'apple', createdAt: t2, updatedAt: t2)); + + final byCreated = await repo.listNotes(sort: NoteSort.createdDesc); + expect(byCreated.first.id, 'b'); // newest created first + + final alpha = await repo.listNotes(sort: NoteSort.alphabetical); + expect(alpha.map((n) => n.text), ['apple', 'banana']); + }); + + test('watchNotes and watchCount emit current state', () async { + final repo = await NoteRepository.openInMemory(); + addTearDown(repo.close); + await repo.upsert(note('a', 'one')); + + expect(await repo.watchNotes().first, hasLength(1)); + expect(await repo.watchCount().first, 1); + }); + }); + + test('nodeId, changeset merge and close', () async { + // Use file-backed DBs: two openInMemory repos would share one `:memory:` + // connection, so they cannot model two independent devices. + final dir = await Directory.systemTemp.createTemp('todo_merge'); + addTearDown(() => dir.delete(recursive: true)); + final source = await NoteRepository.open('${dir.path}/source.db'); + final target = await NoteRepository.open('${dir.path}/target.db'); + addTearDown(target.close); + + expect(source.nodeId, isNotEmpty); + await source.upsert(note('a', 'shared idea')); + final changeset = await source.getChangeset(); + await source.close(); + + // getChangeset serialises hlc/modified as Strings and returns read-only + // rows; merge expects mutable maps with Hlc objects. Rebuild them the + // way the sync layer does after its JSON round-trip. + final revived = { + for (final entry in changeset.entries) + entry.key: [ + for (final record in entry.value) + { + ...record, + 'hlc': Hlc.parse(record['hlc'] as String), + 'modified': Hlc.parse(record['modified'] as String), + }, + ], + }; + await target.merge(revived); + final merged = await target.listNotes(); + expect(merged.single.text, 'shared idea'); + }); } diff --git a/test/note_test.dart b/test/note_test.dart new file mode 100644 index 0000000..0e86ac8 --- /dev/null +++ b/test/note_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; + +void main() { + group('Priority', () { + test('fromValue maps known, legacy and unknown values', () { + expect(Priority.fromValue(1), Priority.low); + expect(Priority.fromValue(2), Priority.medium); + expect(Priority.fromValue(3), Priority.high); + expect(Priority.fromValue(0), Priority.medium); // legacy "none" + expect(Priority.fromValue(null), Priority.medium); + expect(Priority.fromValue(99), Priority.medium); + }); + + test('labels and default', () { + expect(Priority.high.label, 'High'); + expect(Priority.defaultValue, Priority.medium); + }); + }); + + group('Status', () { + test('fromValue maps known and unknown values', () { + expect(Status.fromValue(0), Status.todo); + expect(Status.fromValue(1), Status.inProgress); + expect(Status.fromValue(2), Status.done); + expect(Status.fromValue(3), Status.abandoned); + expect(Status.fromValue(null), Status.todo); + expect(Status.fromValue(42), Status.todo); + }); + + test('labels', () { + expect(Status.inProgress.label, 'In progress'); + expect(Status.abandoned.label, 'Abandoned'); + }); + }); + + test('fromRow builds a Note from a raw column map', () { + final note = Note.fromRow({ + 'id': 'x', + 'text': 'hello', + 'priority': 3, + 'status': 1, + 'created_at': '2026-06-15T09:00:00.000', + 'updated_at': '2026-06-15T10:00:00.000', + }); + expect(note.id, 'x'); + expect(note.text, 'hello'); + expect(note.priority, Priority.high); + expect(note.status, Status.inProgress); + expect(note.createdAt, DateTime(2026, 6, 15, 9)); + expect(note.updatedAt, DateTime(2026, 6, 15, 10)); + }); + + test('fromRow tolerates a null text column', () { + final note = Note.fromRow({ + 'id': 'x', + 'text': null, + 'priority': 2, + 'status': 0, + 'created_at': '2026-06-15T09:00:00.000', + 'updated_at': '2026-06-15T09:00:00.000', + }); + expect(note.text, ''); + }); + + test('copyWith replaces selected fields and keeps identity', () { + final base = Note( + id: 'id', + text: 'a', + priority: Priority.low, + status: Status.todo, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + final updated = base.copyWith( + text: 'b', + priority: Priority.high, + status: Status.done, + updatedAt: DateTime(2026, 2, 2), + ); + expect(updated.id, 'id'); + expect(updated.createdAt, DateTime(2026, 1, 1)); // unchanged + expect(updated.text, 'b'); + expect(updated.priority, Priority.high); + expect(updated.status, Status.done); + expect(updated.updatedAt, DateTime(2026, 2, 2)); + }); + + test('copyWith with no arguments preserves every field', () { + // Exercises the `?? this.x` fallback on each field — the path that the + // selective-replace test above never hits because it always supplies + // a value. + final base = Note( + id: 'id', + text: 'a', + priority: Priority.high, + status: Status.inProgress, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 3, 3), + ); + final clone = base.copyWith(); + expect(clone.id, base.id); + expect(clone.text, base.text); + expect(clone.priority, base.priority); + expect(clone.status, base.status); + expect(clone.createdAt, base.createdAt); + expect(clone.updatedAt, base.updatedAt); + }); +} diff --git a/test/notes_list_screen_test.dart b/test/notes_list_screen_test.dart new file mode 100644 index 0000000..d90cf08 --- /dev/null +++ b/test/notes_list_screen_test.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/ui/notes_list_screen.dart'; + +import 'fake_note_repository.dart'; + +void main() { + Note note( + String id, + String text, { + Priority priority = Priority.medium, + Status status = Status.todo, + }) { + final now = DateTime(2026, 6, 15, 9); + return Note( + id: id, + text: text, + priority: priority, + status: status, + createdAt: now, + updatedAt: now, + ); + } + + Future pumpList( + WidgetTester tester, { + List seed = const [], + }) async { + final repo = FakeNoteRepository(seed); + addTearDown(repo.close); + await tester.pumpWidget( + MaterialApp(home: NotesListScreen(repository: repo)), + ); + await tester.pump(); // flush the initial stream emit + return repo; + } + + testWidgets('renders notes with a status · priority · time subtitle', ( + tester, + ) async { + await pumpList( + tester, + seed: [note('a', 'First note', priority: Priority.high)], + ); + + expect(find.text('First note'), findsOneWidget); + expect(find.textContaining('To do'), findsOneWidget); + expect(find.textContaining('High'), findsOneWidget); + }); + + testWidgets('defaults to hiding Done/Abandoned with no filter badge', ( + tester, + ) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + // The screen's default query hides completed work… + expect(repo.lastFilter!.statuses, {Status.todo, Status.inProgress}); + // …but that default is not surfaced as an active-filter badge. + expect(find.byType(Badge), findsOneWidget); + expect(tester.widget(find.byType(Badge)).isLabelVisible, isFalse); + }); + + testWidgets('search box feeds a debounced query into the filter', ( + tester, + ) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + await tester.enterText(find.byType(TextField), 'diet'); + await tester.pump(const Duration(milliseconds: 300)); // > debounce + + expect(repo.lastFilter!.query, 'diet'); + }); + + testWidgets('sort menu selection updates the query sort', (tester) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + await tester.tap(find.byIcon(Icons.sort)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // menu open + await tester.tap(find.text('Alphabetical').last); + await tester.pump(); + + expect(repo.lastSort, NoteSort.alphabetical); + }); + + testWidgets('filter sheet adds a status and shows the badge', (tester) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + await tester.tap(find.byIcon(Icons.filter_list)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // sheet open + await tester.tap(find.text('Done')); + await tester.pump(); + await tester.tap(find.text('Apply')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // sheet close + + expect(repo.lastFilter!.statuses, contains(Status.done)); + // A non-default selection now surfaces the badge. + expect(tester.widget(find.byType(Badge)).isLabelVisible, isTrue); + }); + + testWidgets('per-note sheet deletes the note', (tester) async { + final repo = await pumpList(tester, seed: [note('a', 'Delete me')]); + + await tester.tap(find.text('Delete me')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // sheet open + await tester.tap(find.text('Delete note')); + await tester.pump(); + + expect(await repo.listNotes(), isEmpty); + }); + + testWidgets('per-note sheet changes status via a chip', (tester) async { + final repo = await pumpList(tester, seed: [note('a', 'Change me')]); + + await tester.tap(find.text('Change me')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('In progress')); + await tester.pump(); + + expect((await repo.listNotes()).single.status, Status.inProgress); + }); + + testWidgets('shows an empty state when there are no notes', (tester) async { + await pumpList(tester); // no seed + // The default filter hides Done/Abandoned, so it's the "no match" + // variant rather than "No notes yet" — either way, an empty message. + expect(find.textContaining('No notes'), findsOneWidget); + }); + + testWidgets('a Created date preset sets a created range on the filter', ( + tester, + ) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + await tester.tap(find.byIcon(Icons.filter_list)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + // "Today" appears under both Created and Last-updated; the first is + // Created. + await tester.tap(find.text('Today').first); + await tester.pump(); + await tester.tap(find.text('Apply')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(repo.lastFilter!.createdFrom, isNotNull); + expect(repo.lastFilter!.createdTo, isNotNull); + }); + + testWidgets('clearing the search box resets the query', (tester) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + await tester.enterText(find.byType(TextField), 'foo'); + await tester.pump(const Duration(milliseconds: 300)); + expect(repo.lastFilter!.query, 'foo'); + + await tester.tap(find.byIcon(Icons.clear)); + await tester.pump(const Duration(milliseconds: 300)); + + expect(repo.lastFilter!.query, isEmpty); + }); + + testWidgets('renders relative-time labels for varied ages', (tester) async { + final now = DateTime.now(); + Note aged(String id, Duration ago) => Note( + id: id, + text: 'note $id', + priority: Priority.medium, + status: Status.todo, + createdAt: now.subtract(ago), + updatedAt: now.subtract(ago), + ); + await pumpList( + tester, + seed: [ + aged('s', const Duration(seconds: 10)), + aged('m', const Duration(minutes: 5)), + aged('h', const Duration(hours: 2)), + aged('d', const Duration(days: 3)), + ], + ); + + expect(find.textContaining('just now'), findsOneWidget); + expect(find.textContaining('5m ago'), findsOneWidget); + expect(find.textContaining('2h ago'), findsOneWidget); + expect(find.textContaining('3d ago'), findsOneWidget); + }); + + testWidgets('Clear all resets the filter sheet to the default', ( + tester, + ) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + await tester.tap(find.byIcon(Icons.filter_list)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Done')); // add a non-default status + await tester.pump(); + await tester.tap(find.text('Clear all')); + await tester.pump(); + await tester.tap(find.text('Apply')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(repo.lastFilter!.statuses, {Status.todo, Status.inProgress}); + }); + + testWidgets('a date range can be set then cleared with Any', (tester) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + await tester.tap(find.byIcon(Icons.filter_list)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('7 days').first); // Created: last 7 days + await tester.pump(); + await tester.tap(find.text('Any').first); // clear that range + await tester.pump(); + await tester.tap(find.text('Apply')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(repo.lastFilter!.createdFrom, isNull); + }); + + testWidgets('Custom… opens the range picker (cancel keeps no range)', ( + tester, + ) async { + final repo = await pumpList(tester, seed: [note('a', 'x')]); + + await tester.tap(find.byIcon(Icons.filter_list)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Custom…').first); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // picker opens + // The full-screen range picker dismisses via a close icon, not a label. + await tester.tap(find.byIcon(Icons.close)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(find.text('Apply')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(repo.lastFilter!.createdFrom, isNull); + }); +} diff --git a/test/notes_markdown_test.dart b/test/notes_markdown_test.dart new file mode 100644 index 0000000..724a82d --- /dev/null +++ b/test/notes_markdown_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/sync/notes_markdown.dart'; + +void main() { + Note note( + String id, + String text, { + Priority priority = Priority.medium, + Status status = Status.todo, + DateTime? createdAt, + DateTime? updatedAt, + }) { + final t = DateTime(2026, 6, 15, 9, 30, 15, 123); + return Note( + id: id, + text: text, + priority: priority, + status: status, + createdAt: createdAt ?? t, + updatedAt: updatedAt ?? t, + ); + } + + test('export then parse round-trips every field', () { + final original = [ + note('a', 'first idea', priority: Priority.high, status: Status.done), + note( + 'b', + 'multi-line\nbody with - dashes\nand 1. a list', + priority: Priority.low, + status: Status.inProgress, + ), + ]; + + final parsed = NotesMarkdown.parse(NotesMarkdown.export(original)); + + expect(parsed, hasLength(2)); + for (var i = 0; i < original.length; i++) { + expect(parsed[i].id, original[i].id); + expect(parsed[i].text, original[i].text); + expect(parsed[i].priority, original[i].priority); + expect(parsed[i].status, original[i].status); + expect(parsed[i].createdAt, original[i].createdAt); + expect(parsed[i].updatedAt, original[i].updatedAt); + } + }); + + test('export of an empty list yields just the header', () { + final out = NotesMarkdown.export([]); + expect(out.trim(), NotesMarkdown.header); + expect(NotesMarkdown.parse(out), isEmpty); + }); + + test('parse tolerates missing/unknown fields with defaults', () { + const content = ''' + + + +a hand-written note with no id +'''; + final parsed = NotesMarkdown.parse(content); + + expect(parsed, hasLength(1)); + expect(parsed.single.text, 'a hand-written note with no id'); + // Missing id => a fresh UUID is generated (non-empty). + expect(parsed.single.id, isNotEmpty); + // Unknown/blank enum names fall back to the defaults. + expect(parsed.single.priority, Priority.medium); + expect(parsed.single.status, Status.todo); + }); + + test('parse ignores text before the first note marker', () { + const content = ''' + +Some preamble a user typed that is not a note. + + +real note +'''; + final parsed = NotesMarkdown.parse(content); + expect(parsed.map((n) => n.text), ['real note']); + }); +} diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart new file mode 100644 index 0000000..838f22e --- /dev/null +++ b/test/settings_screen_test.dart @@ -0,0 +1,393 @@ +import 'dart:convert'; + +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/sync/notes_markdown.dart'; +import 'package:todo/sync/sync_settings.dart'; +import 'package:todo/ui/settings_screen.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import 'fake_note_repository.dart'; + +/// Stub file picker that returns a fixed in-memory file (no disk I/O, so the +/// `_import` flow stays timer-free and deterministic under the widget tester). +class _FakeFileSelector extends FileSelectorPlatform + with MockPlatformInterfaceMixin { + _FakeFileSelector(this.file); + final XFile? file; + + @override + Future openFile({ + List? acceptedTypeGroups, + String? initialDirectory, + String? confirmButtonText, + }) async => file; +} + +/// Stub launcher that records the URL instead of opening it, so `_openPage` +/// can be exercised without a real platform channel. +class _FakeUrlLauncher extends UrlLauncherPlatform + with MockPlatformInterfaceMixin { + String? launched; + + @override + final LinkDelegate? linkDelegate = null; + + @override + Future supportsMode(PreferredLaunchMode mode) async => true; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launched = url; + return true; + } +} + +void main() { + Future pumpSettings( + WidgetTester tester, { + SyncSettings initial = const SyncSettings( + owner: 'kuhyx', + repo: 'todo-sync', + token: 't', + ), + http.Client? httpClient, + List seed = const [], + }) async { + SharedPreferences.setMockInitialValues({}); + // Tall surface so the whole settings ListView builds (its Backup section + // is below the default 800×600 fold and would otherwise be lazy-skipped). + tester.view.physicalSize = const Size(1200, 2800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final repo = FakeNoteRepository(seed); + addTearDown(repo.close); + await tester.pumpWidget( + MaterialApp( + home: SettingsScreen( + initial: initial, + repository: repo, + httpClient: httpClient, + ), + ), + ); + await tester.pump(); + return repo; + } + + testWidgets('renders sync fields and the backup actions', (tester) async { + await pumpSettings(tester); + expect(find.text('Connect GitHub'), findsOneWidget); + expect(find.text('Export notes'), findsOneWidget); + expect(find.text('Import notes'), findsOneWidget); + }); + + testWidgets('Connect GitHub without a client id shows guidance', ( + tester, + ) async { + await pumpSettings(tester); + await tester.tap(find.text('Connect GitHub')); + await tester.pump(); + expect( + find.textContaining('Enter the OAuth App client id'), + findsOneWidget, + ); + }); + + testWidgets('Test connection reports a reachable repo', (tester) async { + final mock = MockClient((_) async => http.Response('{}', 200)); + await pumpSettings(tester, httpClient: mock); + + await tester.tap(find.text('Test connection')); + await tester.pump(); // start + await tester.pump(); // resolve future + rebuild + + expect(find.textContaining('reachable'), findsOneWidget); + }); + + testWidgets('Test connection reports an inaccessible repo', (tester) async { + final mock = MockClient((_) async => http.Response('', 404)); + await pumpSettings(tester, httpClient: mock); + + await tester.tap(find.text('Test connection')); + await tester.pump(); + await tester.pump(); + + expect(find.textContaining('Could not access'), findsOneWidget); + }); + + testWidgets('Test connection surfaces a network error', (tester) async { + final mock = MockClient((_) async => throw Exception('offline')); + await pumpSettings(tester, httpClient: mock); + + await tester.tap(find.text('Test connection')); + await tester.pump(); + await tester.pump(); + + expect(find.textContaining('Error:'), findsOneWidget); + }); + + testWidgets('device flow failure to start shows a message', (tester) async { + final mock = MockClient((_) async => http.Response('nope', 422)); + await pumpSettings( + tester, + initial: const SyncSettings( + owner: 'o', + repo: 'r', + token: '', + clientId: 'cid', + ), + httpClient: mock, + ); + + await tester.tap(find.text('Connect GitHub')); + await tester.pump(); + await tester.pump(); + + expect(find.textContaining('Could not start device flow'), findsOneWidget); + }); + + testWidgets('device flow happy path saves the token', (tester) async { + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + // Token endpoint: authorize immediately. + return http.Response(jsonEncode({'access_token': 'gho_test'}), 200); + }); + + await pumpSettings( + tester, + initial: const SyncSettings( + owner: 'o', + repo: 'r', + token: '', + clientId: 'cid', + ), + httpClient: mock, + ); + + await tester.tap(find.text('Connect GitHub')); + await tester.pump(); // requestDeviceCode + await tester.pump(); // dialog builds, shows the user code + expect(find.text('WXYZ-1234'), findsOneWidget); + + // Let the dialog poll (interval 0) and resolve the token. + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + + expect(find.textContaining('Connected via GitHub'), findsOneWidget); + }); + + testWidgets('Export notes writes the backlog file (desktop)', (tester) async { + await pumpSettings( + tester, + seed: [ + Note( + id: 'n', + text: 'an idea', + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime(2026, 6, 15), + updatedAt: DateTime(2026, 6, 15), + ), + ], + ); + + // _export does real file I/O on desktop, so drive it under runAsync. + await tester.runAsync(() async { + await tester.tap(find.text('Export notes')); + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pump(); + + expect(find.textContaining('Exported'), findsOneWidget); + }); + + testWidgets('Save persists the settings and closes the screen', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + tester.view.physicalSize = const Size(1200, 2800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final repo = FakeNoteRepository(); + addTearDown(repo.close); + + // Push Settings over a base route so _save's Navigator.pop has somewhere + // to return to (popping the root route is a no-op and hides the result). + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SettingsScreen( + initial: const SyncSettings( + owner: 'o', + repo: 'r', + token: 'tok', + ), + repository: repo, + ), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); // route transition + expect(find.text('Connect GitHub'), findsOneWidget); // settings is up + + await tester.tap(find.text('Save')); + await tester.pump(); // run _save (persist + pop) + await tester.pump(const Duration(milliseconds: 400)); // pop transition + + expect(find.text('open'), findsOneWidget); // back on the base route + final saved = await SyncSettings.load(); + expect(saved.owner, 'o'); + expect(saved.token, 'tok'); + }); + + testWidgets('Import notes reads the picked file and merges', (tester) async { + // Round-trip a known note through the export format so the picked file is + // valid input the importer can parse and merge. + final markdown = NotesMarkdown.export([ + Note( + id: 'imported-1', + text: 'an imported idea', + priority: Priority.high, + status: Status.inProgress, + createdAt: DateTime(2026, 6, 15), + updatedAt: DateTime(2026, 6, 15), + ), + ]); + FileSelectorPlatform.instance = _FakeFileSelector( + XFile.fromData(utf8.encode(markdown), name: 'backlog.md'), + ); + + final repo = await pumpSettings(tester); + + await tester.tap(find.text('Import notes')); + await tester.pump(); // openFile resolves (in-memory) + await tester.pump(); // read + parse + merge + setState + + expect(find.textContaining('Imported'), findsOneWidget); + expect((await repo.listNotes()).single.text, 'an imported idea'); + }); + + testWidgets('Import shows nothing when the picker is cancelled', ( + tester, + ) async { + FileSelectorPlatform.instance = _FakeFileSelector(null); // user cancels + final repo = await pumpSettings(tester); + + await tester.tap(find.text('Import notes')); + await tester.pump(); + await tester.pump(); + + expect(find.textContaining('Imported'), findsNothing); + expect(await repo.listNotes(), isEmpty); + }); + + testWidgets('device dialog: failed poll shows the error and Open launches', ( + tester, + ) async { + final launcher = _FakeUrlLauncher(); + UrlLauncherPlatform.instance = launcher; + + // _openPage copies the code to the clipboard first; there's no clipboard + // plugin in the test host, so stub the channel to succeed. + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + addTearDown( + () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), + ); + + final mock = MockClient((req) async { + if (req.url.path.contains('device/code')) { + return http.Response( + jsonEncode({ + 'device_code': 'dev123', + 'user_code': 'WXYZ-1234', + 'verification_uri': 'https://github.com/login/device', + 'interval': 0, + 'expires_in': 900, + }), + 200, + ); + } + // Token endpoint: a terminal error ends the poll loop cleanly (no + // lingering timer to trip the tester's pending-timer guard). + return http.Response( + jsonEncode({'error': 'access_denied', 'error_description': 'nope'}), + 200, + ); + }); + + await pumpSettings( + tester, + initial: const SyncSettings( + owner: 'o', + repo: 'r', + token: '', + clientId: 'cid', + ), + httpClient: mock, + ); + + await tester.tap(find.text('Connect GitHub')); + await tester.pump(); // requestDeviceCode + await tester.pump(); // dialog builds, poll starts (interval 0) + expect(find.text('WXYZ-1234'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires + await tester.pump(); // token error throws → _error set + expect(find.textContaining('nope'), findsOneWidget); // error rendered + + // Tap the "open on GitHub" action: copies the code and launches the URL. + // _openPage awaits Clipboard.setData then the launcher's supportsMode + + // launchUrl; pumpAndSettle drains them (no spinner is animating now that + // the error is shown, so it settles). + await tester.tap(find.byIcon(Icons.open_in_new)); + await tester.pumpAndSettle(); + expect(launcher.launched, 'https://github.com/login/device'); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); // finish the dialog pop animation + expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed + }); +} diff --git a/test/sync_service_test.dart b/test/sync_service_test.dart new file mode 100644 index 0000000..5ef9ad5 --- /dev/null +++ b/test/sync_service_test.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/github_client.dart'; +import 'package:todo/sync/sync_service.dart'; + +void main() { + setUpAll(sqfliteFfiInit); + + test('sync pulls and merges another device, then pushes its own', () async { + final dir = await Directory.systemTemp.createTemp('todo_sync'); + addTearDown(() => dir.delete(recursive: true)); + + // Build a second device's changeset and serialise it the way it would + // be stored in the repo (hlc as a String, base64 in the API response). + final other = await NoteRepository.open('${dir.path}/other.db'); + await other.upsert( + Note( + id: 'x', + text: 'from other device', + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime(2026, 6, 15), + updatedAt: DateTime(2026, 6, 15), + ), + ); + final otherJson = const JsonEncoder.withIndent( + ' ', + ).convert(await other.getChangeset()); + await other.close(); + final fileResponse = jsonEncode({ + 'content': base64.encode(utf8.encode(otherJson)), + }); + + const otherFile = 'otherNode.json'; + final listResponse = jsonEncode([ + { + 'type': 'file', + 'name': otherFile, + 'path': 'changesets/$otherFile', + 'sha': 'sha-other', + }, + ]); + + var putCount = 0; + final mock = MockClient((req) async { + if (req.method == 'PUT') { + putCount++; + return http.Response('{}', 200); + } + if (req.url.path.endsWith('/contents/changesets')) { + return http.Response(listResponse, 200); // directory listing + } + return http.Response(fileResponse, 200); // the other device's file + }); + + final local = await NoteRepository.open('${dir.path}/local.db'); + addTearDown(local.close); + final github = GitHubClient( + owner: 'o', + repo: 'r', + token: 't', + httpClient: mock, + ); + + final result = await const SyncService().sync(local, github); + + expect(result.mergedDevices, 1); + expect(result.pushed, isTrue); + expect(result.toString(), contains('mergedDevices: 1')); + expect(putCount, 1); // pushed our own changeset + final texts = (await local.listNotes()).map((n) => n.text); + expect(texts, contains('from other device')); + }); + + test('sync with no remote files still pushes own changeset', () async { + final dir = await Directory.systemTemp.createTemp('todo_sync_empty'); + addTearDown(() => dir.delete(recursive: true)); + + var putCount = 0; + final mock = MockClient((req) async { + if (req.method == 'PUT') { + putCount++; + return http.Response('{}', 200); + } + return http.Response('', 404); // empty/missing changesets dir + }); + + final local = await NoteRepository.open('${dir.path}/local.db'); + addTearDown(local.close); + final github = GitHubClient( + owner: 'o', + repo: 'r', + token: 't', + httpClient: mock, + ); + + final result = await const SyncService().sync(local, github); + expect(result.mergedDevices, 0); + expect(result.pushed, isTrue); + expect(putCount, 1); + }); + + test('sync updates its own already-present changeset file', () async { + final dir = await Directory.systemTemp.createTemp('todo_sync_own'); + addTearDown(() => dir.delete(recursive: true)); + + final local = await NoteRepository.open('${dir.path}/local.db'); + addTearDown(local.close); + + // The remote listing already contains *this* device's changeset file. + // The sync must recognise it (by node id), skip merging itself, remember + // the sha, and PUT an update rather than treating it as a peer device. + final ownFile = '${local.nodeId}.json'; + final listResponse = jsonEncode([ + { + 'type': 'file', + 'name': ownFile, + 'path': 'changesets/$ownFile', + 'sha': 'own-sha-123', + }, + ]); + + String? putSha; + final mock = MockClient((req) async { + if (req.method == 'PUT') { + final body = jsonDecode(req.body) as Map; + putSha = body['sha'] as String?; + return http.Response('{}', 200); + } + return http.Response(listResponse, 200); + }); + + final github = GitHubClient( + owner: 'o', + repo: 'r', + token: 't', + httpClient: mock, + ); + + final result = await const SyncService().sync(local, github); + expect(result.mergedDevices, 0); // own file is not a peer to merge + expect(result.pushed, isTrue); + expect(putSha, 'own-sha-123'); // updated in place using the remembered sha + }); +} diff --git a/test/sync_settings_test.dart b/test/sync_settings_test.dart new file mode 100644 index 0000000..cbef6b3 --- /dev/null +++ b/test/sync_settings_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:todo/sync/sync_settings.dart'; + +void main() { + test( + 'load returns the kuhyx/todo-sync defaults on a fresh install', + () async { + SharedPreferences.setMockInitialValues({}); + final s = await SyncSettings.load(); + expect(s.owner, 'kuhyx'); + expect(s.repo, 'todo-sync'); + expect(s.token, ''); + expect(s.clientId, ''); + }, + ); + + test('save then load round-trips all fields', () async { + SharedPreferences.setMockInitialValues({}); + await const SyncSettings( + owner: 'me', + repo: 'notes', + token: 'tok', + clientId: 'cid', + ).save(); + + final s = await SyncSettings.load(); + expect(s.owner, 'me'); + expect(s.repo, 'notes'); + expect(s.token, 'tok'); + expect(s.clientId, 'cid'); + }); + + test('isConfigured requires owner, repo and token', () { + expect( + const SyncSettings(owner: 'o', repo: 'r', token: 't').isConfigured, + isTrue, + ); + expect( + const SyncSettings(owner: 'o', repo: 'r', token: '').isConfigured, + isFalse, + ); + }); + + test('canUseDeviceFlow needs a client id', () { + expect( + const SyncSettings( + owner: '', + repo: '', + token: '', + clientId: 'c', + ).canUseDeviceFlow, + isTrue, + ); + expect( + const SyncSettings(owner: '', repo: '', token: '').canUseDeviceFlow, + isFalse, + ); + }); + + test('copyWith overrides only the given fields', () { + const base = SyncSettings(owner: 'o', repo: 'r', token: 't', clientId: 'c'); + final next = base.copyWith(token: 'new'); + expect(next.owner, 'o'); + expect(next.repo, 'r'); + expect(next.token, 'new'); + expect(next.clientId, 'c'); + }); +} diff --git a/tool/sync_smoke.dart b/tool/sync_smoke.dart index 68cb986..a21cdcd 100644 --- a/tool/sync_smoke.dart +++ b/tool/sync_smoke.dart @@ -25,11 +25,8 @@ Future main() async { // Throwaway directory so we never pollute the real `changesets/`. const service = SyncService(changesetDir: 'changesets_smoketest'); - GitHubClient client() => GitHubClient( - owner: 'kuhyx', - repo: 'todo-sync', - token: token, - ); + GitHubClient client() => + GitHubClient(owner: 'kuhyx', repo: 'todo-sync', token: token); final deviceA = await NoteRepository.openInMemory(); final deviceB = await NoteRepository.openInMemory(); @@ -57,7 +54,8 @@ Future main() async { 'Idea from device A @ $stamp', 'Idea from device B @ $stamp', }; - final converged = aNotes.containsAll(expected) && bNotes.containsAll(expected); + final converged = + aNotes.containsAll(expected) && bNotes.containsAll(expected); // Cleanup: remove the throwaway changeset files. final cleanup = client(); @@ -73,7 +71,9 @@ Future main() async { await deviceB.close(); if (converged) { - stdout.writeln('\n✅ PASS: both devices converged to both notes via GitHub.'); + stdout.writeln( + '\n✅ PASS: both devices converged to both notes via GitHub.', + ); exit(0); } else { stdout.writeln('\n❌ FAIL: devices did not converge. Expected $expected.'); @@ -83,11 +83,14 @@ Future main() async { Future _insert(NoteRepository repo, String text) async { final now = DateTime.now(); - await repo.upsert(Note( - id: '${now.microsecondsSinceEpoch}-${text.hashCode}', - text: text, - priority: Priority.none, - createdAt: now, - updatedAt: now, - )); + await repo.upsert( + Note( + id: '${now.microsecondsSinceEpoch}-${text.hashCode}', + text: text, + priority: Priority.medium, + status: Status.todo, + createdAt: now, + updatedAt: now, + ), + ); }