Add list filters/sort, status, priority rework, export/import, structured template

Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
  and last-updated (AND-combined), a priority filter, and a new status
  filter. Default view hides Done/Abandoned and renders as "unfiltered"
  (no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-15 16:52:59 +02:00
parent 7f51e95396
commit 7f84414c87
27 changed files with 3387 additions and 184 deletions

7
.gitignore vendored
View File

@ -43,3 +43,10 @@ app.*.map.json
/android/app/debug /android/app/debug
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# Exported personal notes — never commit (contains private idea content)
BACKLOG.md
todo-backlog.md
# Android Gradle build output
/android/build/

View File

@ -8,24 +8,59 @@ library;
/// Priority tier for a note, used for sorting and visual grouping. /// 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 { enum Priority {
none(0), low(1, 'Low'),
low(1), medium(2, 'Medium'),
medium(2), high(3, 'High');
high(3);
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. /// Integer persisted in the database; higher means more important.
final int value; final int value;
/// Rebuilds a [Priority] from its stored [value], defaulting to [none] /// Human-readable label for UI controls (pickers, filters, list rows).
/// for any unknown/legacy value so reads never throw. 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) { static Priority fromValue(int? value) {
return Priority.values.firstWhere( return Priority.values.firstWhere(
(p) => p.value == value, (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.id,
required this.text, required this.text,
required this.priority, required this.priority,
required this.status,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
}); });
@ -49,6 +85,9 @@ class Note {
/// Priority tier for sorting/filtering. /// Priority tier for sorting/filtering.
final Priority priority; final Priority priority;
/// Workflow state (to do / in progress / done / abandoned).
final Status status;
/// When the note was first created (set once, never changed). /// When the note was first created (set once, never changed).
final DateTime createdAt; final DateTime createdAt;
@ -64,17 +103,24 @@ class Note {
id: row['id'] as String, id: row['id'] as String,
text: (row['text'] as String?) ?? '', text: (row['text'] as String?) ?? '',
priority: Priority.fromValue(row['priority'] as int?), priority: Priority.fromValue(row['priority'] as int?),
status: Status.fromValue(row['status'] as int?),
createdAt: DateTime.parse(row['created_at'] as String), createdAt: DateTime.parse(row['created_at'] as String),
updatedAt: DateTime.parse(row['updated_at'] as String), updatedAt: DateTime.parse(row['updated_at'] as String),
); );
} }
/// Returns a copy with selected fields replaced. /// 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( return Note(
id: id, id: id,
text: text ?? this.text, text: text ?? this.text,
priority: priority ?? this.priority, priority: priority ?? this.priority,
status: status ?? this.status,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
); );

View File

@ -3,11 +3,27 @@ import 'package:sqlite_crdt/sqlite_crdt.dart';
import 'note.dart'; import 'note.dart';
/// How the history list should be ordered. /// How the history list should be ordered.
enum NoteSort { enum NoteSort { createdDesc, modifiedDesc, alphabetical, priorityDesc }
createdDesc,
modifiedDesc, /// Summary of an [NoteRepository.importNotes] run, for user feedback.
alphabetical, class ImportOutcome {
priorityDesc, 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. /// Local-first persistence for [Note]s, backed by a CRDT SQLite database.
@ -29,22 +45,9 @@ class NoteRepository {
static Future<NoteRepository> open(String path) async { static Future<NoteRepository> open(String path) async {
final crdt = await SqliteCrdt.open( final crdt = await SqliteCrdt.open(
path, path,
version: 1, version: _schemaVersion,
onCreate: (db, version) async { onCreate: _onCreate,
// Plain columns only; the CRDT layer adds its own bookkeeping onUpgrade: _onUpgrade,
// 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)
)
''');
},
); );
return NoteRepository._(crdt); return NoteRepository._(crdt);
} }
@ -52,21 +55,50 @@ class NoteRepository {
/// Opens a transient in-memory database; intended for tests. /// Opens a transient in-memory database; intended for tests.
static Future<NoteRepository> openInMemory() async { static Future<NoteRepository> openInMemory() async {
final crdt = await SqliteCrdt.openInMemory( final crdt = await SqliteCrdt.openInMemory(
version: 1, version: _schemaVersion,
onCreate: (db, version) async { 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<void> _onCreate(CrdtTableExecutor db, int version) async {
await db.execute(''' await db.execute('''
CREATE TABLE notes ( CREATE TABLE notes (
id TEXT NOT NULL, id TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '', text TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0, priority INTEGER NOT NULL DEFAULT 2,
status INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (id) 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<void> _onUpgrade(CrdtTableExecutor db, int from, int to) async {
if (from < 2) {
await db.execute(
'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0',
); );
return NoteRepository._(crdt); }
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]. /// Inserts a new note or updates the existing one with the same [id].
@ -76,17 +108,19 @@ class NoteRepository {
Future<void> upsert(Note note) async { Future<void> upsert(Note note) async {
await _crdt.execute( await _crdt.execute(
''' '''
INSERT INTO notes (id, text, priority, created_at, updated_at) INSERT INTO notes (id, text, priority, status, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
text = ?2, text = ?2,
priority = ?3, priority = ?3,
updated_at = ?5 status = ?4,
updated_at = ?6
''', ''',
[ [
note.id, note.id,
note.text, note.text,
note.priority.value, note.priority.value,
note.status.value,
note.createdAt.toIso8601String(), note.createdAt.toIso8601String(),
note.updatedAt.toIso8601String(), note.updatedAt.toIso8601String(),
], ],
@ -99,25 +133,71 @@ class NoteRepository {
await _crdt.execute('DELETE FROM notes WHERE id = ?1', [id]); 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<ImportOutcome> importNotes(List<Note> 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<List<Note>> listNotes({ Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc, NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) async { }) async {
final rows = await _crdt final (where, args) = _buildWhere(filter);
.query('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}'); final rows = await _crdt.query(
'SELECT * FROM notes WHERE $where ${_orderBy(sort)}',
args,
);
return rows.map(Note.fromRow).toList(); return rows.map(Note.fromRow).toList();
} }
/// Emits the ordered note list and re-emits whenever the table changes, /// Emits the matching, ordered note list and re-emits whenever the table
/// so the UI can stay in sync without manual refreshes. /// 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<List<Note>> watchNotes({ Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.modifiedDesc, NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) { }) {
final (where, args) = _buildWhere(filter);
return _crdt 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()); .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<int> 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 /// 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. /// in the sync repo so two devices never write the same file.
String get nodeId => _crdt.nodeId; String get nodeId => _crdt.nodeId;
@ -146,4 +226,166 @@ class NoteRepository {
return 'ORDER BY priority DESC, updated_at DESC'; 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<Object?>) _buildWhere(NoteFilter filter) {
final clauses = <String>['is_deleted = 0'];
final args = <Object?>[];
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<String> clauses,
List<Object?> 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<Priority> priorities;
/// Notes must have one of these statuses. Empty means "any status".
final Set<Status> 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<Priority>? priorities,
Set<Status>? 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),
);
}
} }

View File

@ -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 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';

View File

@ -76,11 +76,13 @@ class GitHubClient {
return decoded return decoded
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.where((e) => e['type'] == 'file') .where((e) => e['type'] == 'file')
.map((e) => GitHubFile( .map(
(e) => GitHubFile(
name: e['name'] as String, name: e['name'] as String,
path: e['path'] as String, path: e['path'] as String,
sha: e['sha'] as String, sha: e['sha'] as String,
)) ),
)
.toList(); .toList();
} }
@ -148,7 +150,10 @@ class GitHubClient {
void _ensureOk(http.Response res, String action) { void _ensureOk(http.Response res, String action) {
if (res.statusCode < 200 || res.statusCode >= 300) { 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}',
);
} }
} }
} }

View File

@ -82,8 +82,7 @@ class GitHubDeviceAuth {
static const _deviceCodeUrl = 'https://github.com/login/device/code'; static const _deviceCodeUrl = 'https://github.com/login/device/code';
static const _tokenUrl = 'https://github.com/login/oauth/access_token'; static const _tokenUrl = 'https://github.com/login/oauth/access_token';
static const _grantType = static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code';
'urn:ietf:params:oauth:grant-type:device_code';
/// Step 1: ask GitHub for a device + user code. /// Step 1: ask GitHub for a device + user code.
Future<DeviceCodeResponse> requestDeviceCode() async { Future<DeviceCodeResponse> requestDeviceCode() async {
@ -96,7 +95,8 @@ class GitHubDeviceAuth {
throw DeviceAuthException('http_${res.statusCode}', res.body); throw DeviceAuthException('http_${res.statusCode}', res.body);
} }
return DeviceCodeResponse.fromJson( return DeviceCodeResponse.fromJson(
jsonDecode(res.body) as Map<String, dynamic>); jsonDecode(res.body) as Map<String, dynamic>,
);
} }
/// Step 2: poll until the user authorizes, returning the access token. /// Step 2: poll until the user authorizes, returning the access token.
@ -132,7 +132,9 @@ class GitHubDeviceAuth {
intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5; intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5;
case final String error: case final String error:
throw DeviceAuthException( throw DeviceAuthException(
error, (json['error_description'] as String?) ?? error); error,
(json['error_description'] as String?) ?? error,
);
case null: case null:
throw DeviceAuthException('unknown', 'Unexpected response: $json'); throw DeviceAuthException('unknown', 'Unexpected response: $json');
} }

View File

@ -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 = '<!-- todo-backlog v1 -->';
/// 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'^<!--\s*@note\s+(.*?)\s*-->[ \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<Note> notes) {
final buffer = StringBuffer()
..writeln(header)
..writeln();
for (final note in notes) {
buffer
..writeln(
'<!-- @note id="${note.id}" priority="${note.priority.name}" '
'status="${note.status.name}" '
'created="${note.createdAt.toIso8601String()}" '
'updated="${note.updatedAt.toIso8601String()}" -->',
)
..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<Note> parse(String content) {
final markers = _markerPattern.allMatches(content).toList();
final notes = <Note>[];
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<String, String> _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<T extends Enum>(
List<T> values,
String? name,
T fallback,
) {
return values.firstWhere((v) => v.name == name, orElse: () => fallback);
}
}

View File

@ -7,10 +7,7 @@ import 'github_client.dart';
/// Outcome of a sync run, for surfacing in the UI. /// Outcome of a sync run, for surfacing in the UI.
class SyncResult { class SyncResult {
const SyncResult({ const SyncResult({required this.mergedDevices, required this.pushed});
required this.mergedDevices,
required this.pushed,
});
/// How many other devices' changesets were pulled and merged. /// How many other devices' changesets were pulled and merged.
final int mergedDevices; final int mergedDevices;

View File

@ -28,6 +28,29 @@ class CaptureScreen extends StatefulWidget {
class _CaptureScreenState extends State<CaptureScreen> { class _CaptureScreenState extends State<CaptureScreen> {
static const _uuid = Uuid(); static const _uuid = Uuid();
/// Placeholder for the note's title line; selected on reset so the first
/// keystroke replaces it.
static const _titlePlaceholder = '<imperative title>';
/// The structured scaffold pre-filled into every new note (see the
/// `<work_backlog>` 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 TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
@ -37,6 +60,11 @@ class _CaptureScreenState extends State<CaptureScreen> {
DateTime? _draftCreatedAt; DateTime? _draftCreatedAt;
DateTime? _lastSavedAt; 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(); final SyncService _syncService = const SyncService();
SyncSettings? _settings; SyncSettings? _settings;
bool _syncing = false; bool _syncing = false;
@ -44,11 +72,29 @@ class _CaptureScreenState extends State<CaptureScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_resetToTemplate();
SyncSettings.load().then((s) { SyncSettings.load().then((s) {
if (mounted) setState(() => _settings = 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 @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
@ -62,7 +108,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
if (!mounted) return; if (!mounted) return;
final result = await Navigator.of(context).push<SyncSettings>( final result = await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => SettingsScreen(initial: current), builder: (_) =>
SettingsScreen(initial: current, repository: widget.repository),
), ),
); );
if (result != null && mounted) setState(() => _settings = result); if (result != null && mounted) setState(() => _settings = result);
@ -112,7 +159,9 @@ class _CaptureScreenState extends State<CaptureScreen> {
/// the first non-empty keystroke so empty drafts never hit storage. /// the first non-empty keystroke so empty drafts never hit storage.
Future<void> _onChanged(String text) async { Future<void> _onChanged(String text) async {
if (_draftId == null) { 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(); _draftId = _uuid.v4();
_draftCreatedAt = DateTime.now(); _draftCreatedAt = DateTime.now();
} }
@ -121,7 +170,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
Note( Note(
id: _draftId!, id: _draftId!,
text: text, text: text,
priority: Priority.none, priority: _draftPriority,
status: _draftStatus,
createdAt: _draftCreatedAt!, createdAt: _draftCreatedAt!,
updatedAt: now, updatedAt: now,
), ),
@ -129,17 +179,51 @@ class _CaptureScreenState extends State<CaptureScreen> {
if (mounted) setState(() => _lastSavedAt = now); 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<void> _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<void> _setStatus(Status status) async {
setState(() => _draftStatus = status);
await _persistDraftMeta();
}
/// Re-saves the draft's metadata when only priority/status changed.
Future<void> _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() { 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(() { setState(() {
_controller.clear(); _resetToTemplate();
_draftId = null; _draftId = null;
_draftCreatedAt = null; _draftCreatedAt = null;
_lastSavedAt = null; _lastSavedAt = null;
_draftPriority = Priority.defaultValue;
_draftStatus = Status.todo;
}); });
_focusNode.requestFocus(); _focusNode.requestFocus();
if (hadText) { if (saved) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Idea saved locally'), content: Text('Idea saved locally'),
@ -157,10 +241,10 @@ class _CaptureScreenState extends State<CaptureScreen> {
title: const Text('Capture'), title: const Text('Capture'),
actions: [ actions: [
// Live count of stored notes, proving local persistence. // Live count of stored notes, proving local persistence.
StreamBuilder<List<Note>>( StreamBuilder<int>(
stream: widget.repository.watchNotes(), stream: widget.repository.watchCount(),
builder: (context, snapshot) { builder: (context, snapshot) {
final count = snapshot.data?.length ?? 0; final count = snapshot.data ?? 0;
return Padding( return Padding(
padding: const EdgeInsets.only(right: 4), padding: const EdgeInsets.only(right: 4),
child: Center(child: Text('$count saved')), child: Center(child: Text('$count saved')),
@ -195,6 +279,32 @@ class _CaptureScreenState extends State<CaptureScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Pickers sit above the editor so the bottom-right Save FAB
// never overlaps them.
Row(
children: [
Expanded(
child: _MetaDropdown<Priority>(
label: 'Priority',
value: _draftPriority,
values: Priority.values,
labelOf: (p) => p.label,
onChanged: _setPriority,
),
),
const SizedBox(width: 12),
Expanded(
child: _MetaDropdown<Status>(
label: 'Status',
value: _draftStatus,
values: Status.values,
labelOf: (s) => s.label,
onChanged: _setStatus,
),
),
],
),
const SizedBox(height: 12),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _controller, controller: _controller,
@ -213,12 +323,16 @@ class _CaptureScreenState extends State<CaptureScreen> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( // Leave room so the Save FAB doesn't cover the save indicator.
Padding(
padding: const EdgeInsets.only(right: 96),
child: Text(
_lastSavedAt == null _lastSavedAt == null
? 'Autosaves as you type' ? 'Autosaves as you type'
: 'Saved locally at ${_formatTime(_lastSavedAt!)}', : 'Saved locally at ${_formatTime(_lastSavedAt!)}',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
),
], ],
), ),
), ),
@ -236,3 +350,49 @@ class _CaptureScreenState extends State<CaptureScreen> {
return '${two(t.hour)}:${two(t.minute)}:${two(t.second)}'; 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<T> 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<T> values;
final String Function(T) labelOf;
final ValueChanged<T> 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<T>(
value: value,
isDense: true,
isExpanded: true,
items: [
for (final v in values)
DropdownMenuItem<T>(value: v, child: Text(labelOf(v))),
],
onChanged: (v) {
if (v != null) onChanged(v);
},
),
),
);
}
}

View File

@ -1,47 +1,260 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/note.dart'; import '../data/note.dart';
import '../data/note_repository.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<Status> 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 /// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
/// created/modified/alphabetical/priority is deferred). Its job today is to /// only owns transient view state ([NoteSort] + [NoteFilter]) and rebuilds
/// show that synced items actually landed locally. /// the watch stream when that state changes. The stream is memoised so a
class NotesListScreen extends StatelessWidget { /// rebuild (e.g. a search keystroke) does not churn a new DB subscription.
class NotesListScreen extends StatefulWidget {
const NotesListScreen({required this.repository, super.key}); const NotesListScreen({required this.repository, super.key});
final NoteRepository repository; final NoteRepository repository;
@override
State<NotesListScreen> createState() => _NotesListScreenState();
}
class _NotesListScreenState extends State<NotesListScreen> {
/// 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<Status> 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<List<Note>> _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<void> _openFilters() async {
final edited = await showModalBottomSheet<NoteFilter>(
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<void> _openNoteActions(Note note) async {
await showModalBottomSheet<void>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final badgeCount = _badgeCount;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Notes')), appBar: AppBar(
body: StreamBuilder<List<Note>>( title: const Text('Notes'),
stream: repository.watchNotes(), actions: [
PopupMenuButton<NoteSort>(
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<List<Note>>(
stream: _stream,
builder: (context, snapshot) { builder: (context, snapshot) {
final notes = snapshot.data ?? const <Note>[]; final notes = snapshot.data ?? const <Note>[];
if (notes.isEmpty) { if (notes.isEmpty) {
return const Center(child: Text('No notes yet')); return Center(
child: Text(
_filter.isEmpty
? 'No notes yet'
: 'No notes match these filters',
),
);
} }
return ListView.separated( return ListView.separated(
itemCount: notes.length, itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) { itemBuilder: (context, i) => _NoteTile(
final note = notes[i]; 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; final firstLine = note.text.split('\n').first;
// Every note has a status and a priority now, so both are always shown.
final meta = <String>[
note.status.label,
note.priority.label,
'edited ${_relative(note.updatedAt)}',
].join(' · ');
return ListTile( return ListTile(
title: Text( title: Text(
firstLine.isEmpty ? '(empty)' : firstLine, firstLine.isEmpty ? '(empty)' : firstLine,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text('edited ${_relative(note.updatedAt)}'), subtitle: Text(meta),
); trailing: const Icon(Icons.more_vert),
}, onTap: onTap,
);
},
),
); );
} }
@ -54,3 +267,389 @@ class NotesListScreen extends StatelessWidget {
return '${d.inDays}d ago'; 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<void> Function(Note) onChanged;
final Future<void> 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<void> _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<Status>(
label: 'Status',
values: Status.values,
selected: {_status},
labelOf: (s) => s.label,
onSelected: (s) {
setState(() => _status = s);
_persist();
},
),
const SizedBox(height: 12),
_EnumChips<Priority>(
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<T> extends StatelessWidget {
const _EnumChips({
required this.label,
required this.values,
required this.selected,
required this.labelOf,
required this.onSelected,
});
final String label;
final List<T> values;
final Set<T> selected;
final String Function(T) labelOf;
final ValueChanged<T> 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<Priority> _priorities = {...widget.initial.priorities};
late Set<Status> _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<T>(Set<T> 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<Status>(
label: 'Status',
values: Status.values,
selected: _statuses,
labelOf: (s) => s.label,
onToggle: (s) => _toggle(_statuses, s),
),
const SizedBox(height: 12),
_MultiChips<Priority>(
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<T> extends StatelessWidget {
const _MultiChips({
required this.label,
required this.values,
required this.selected,
required this.labelOf,
required this.onToggle,
});
final String label;
final List<T> values;
final Set<T> selected;
final String Function(T) labelOf;
final ValueChanged<T> 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<void> _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');
}

View File

@ -1,34 +1,57 @@
import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../data/note_repository.dart';
import '../sync/github_client.dart'; import '../sync/github_client.dart';
import '../sync/github_device_auth.dart'; import '../sync/github_device_auth.dart';
import '../sync/notes_markdown.dart';
import '../sync/sync_settings.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** /// Primary sync path: the "Connect GitHub" button runs the OAuth **device
/// (authorize in a browser, no token pasting). The manual token field /// flow** (authorize in a browser, no token pasting). The manual token field
/// remains as a fallback. /// remains as a fallback. The Backup section exports/imports all notes as a
/// single Markdown file (see [NotesMarkdown]).
class SettingsScreen extends StatefulWidget { 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 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 @override
State<SettingsScreen> createState() => _SettingsScreenState(); State<SettingsScreen> createState() => _SettingsScreenState();
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
late final TextEditingController _owner = late final TextEditingController _owner = TextEditingController(
TextEditingController(text: widget.initial.owner); text: widget.initial.owner,
late final TextEditingController _repo = );
TextEditingController(text: widget.initial.repo); late final TextEditingController _repo = TextEditingController(
late final TextEditingController _token = text: widget.initial.repo,
TextEditingController(text: widget.initial.token); );
late final TextEditingController _clientId = late final TextEditingController _token = TextEditingController(
TextEditingController(text: widget.initial.clientId); text: widget.initial.token,
);
late final TextEditingController _clientId = TextEditingController(
text: widget.initial.clientId,
);
bool _testing = false; bool _testing = false;
String? _status; String? _status;
@ -56,7 +79,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() => _status = 'Enter the OAuth App client id first.'); setState(() => _status = 'Enter the OAuth App client id first.');
return; return;
} }
final auth = GitHubDeviceAuth(clientId: clientId); final auth = GitHubDeviceAuth(
clientId: clientId,
httpClient: widget.httpClient,
);
try { try {
final device = await auth.requestDeviceCode(); final device = await auth.requestDeviceCode();
if (!mounted) return; if (!mounted) return;
@ -85,12 +111,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
_status = null; _status = null;
}); });
final s = _current; 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 { try {
final ok = await client.canAccessRepo(); final ok = await client.canAccessRepo();
setState(() => _status = ok setState(
() => _status = ok
? 'Connected — repo is reachable.' ? 'Connected — repo is reachable.'
: 'Could not access ${s.owner}/${s.repo}. Check token scope.'); : 'Could not access ${s.owner}/${s.repo}. Check token scope.',
);
} catch (e) { } catch (e) {
setState(() => _status = 'Error: $e'); setState(() => _status = 'Error: $e');
} finally { } finally {
@ -105,6 +138,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (mounted) Navigator.of(context).pop(s); 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<void> _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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -128,8 +231,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('Connect with GitHub', Text(
style: Theme.of(context).textTheme.titleMedium), 'Connect with GitHub',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: _clientId, controller: _clientId,
@ -148,8 +253,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
const Divider(), const Divider(),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('Or paste a token (fallback)', Text(
style: Theme.of(context).textTheme.titleMedium), 'Or paste a token (fallback)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: _token, controller: _token,
@ -182,6 +289,32 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
], ],
), ),
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) ...[ if (_status != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text(_status!, style: Theme.of(context).textTheme.bodyMedium), Text(_status!, style: Theme.of(context).textTheme.bodyMedium),

View File

@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { 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 = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux url_launcher_linux
) )

View File

@ -73,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.3" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@ -113,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: file:
dependency: transitive dependency: transitive
description: description:
@ -121,6 +137,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -264,6 +344,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" 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: native_toolchain_c:
dependency: transitive dependency: transitive
description: description:
@ -353,7 +441,7 @@ packages:
source: hosted source: hosted
version: "3.1.6" version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: "direct dev"
description: description:
name: plugin_platform_interface name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
@ -392,6 +480,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -614,7 +718,7 @@ packages:
source: hosted source: hosted
version: "3.2.5" version: "3.2.5"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: "direct dev"
description: description:
name: url_launcher_platform_interface name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
@ -669,6 +773,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "6.3.0"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -42,7 +42,8 @@ dependencies:
http: ^1.6.0 http: ^1.6.0
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
url_launcher: ^6.3.2 url_launcher: ^6.3.2
share_plus: ^13.1.0
file_selector: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
@ -54,6 +55,12 @@ dev_dependencies:
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 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 # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

View File

@ -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<FakeNoteRepository> 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('<imperative title>'), 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('<imperative title>'), 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<Priority>),
);
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<Status>),
);
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);
});
}

View File

@ -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<Note>? initial]) : _notes = [...?initial];
final List<Note> _notes;
final _controller = StreamController<List<Note>>.broadcast();
NoteSort? lastSort;
NoteFilter? lastFilter;
/// Emits the current snapshot to a new subscriber first (so late-binding
/// [StreamBuilder]s get the seed), then forwards subsequent changes.
Stream<List<Note>> _snapshots() async* {
yield List.unmodifiable(_notes);
yield* _controller.stream;
}
void _emit() {
if (!_controller.isClosed) _controller.add(List.unmodifiable(_notes));
}
@override
Future<void> upsert(Note note) async {
_notes
..removeWhere((n) => n.id == note.id)
..add(note);
_emit();
}
@override
Future<void> delete(String id) async {
_notes.removeWhere((n) => n.id == id);
_emit();
}
@override
Future<ImportOutcome> importNotes(List<Note> incoming) async {
var added = 0;
var updated = 0;
var skipped = 0;
for (final note in incoming) {
final i = _notes.indexWhere((n) => n.id == note.id);
if (i < 0) {
_notes.add(note);
added++;
} else if (note.updatedAt.isAfter(_notes[i].updatedAt)) {
_notes[i] = note;
updated++;
} else {
skipped++;
}
}
_emit();
return ImportOutcome(added: added, updated: updated, skipped: skipped);
}
@override
Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) async => List.unmodifiable(_notes);
@override
Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) {
lastSort = sort;
lastFilter = filter;
return _snapshots();
}
@override
Stream<int> watchCount() => _snapshots().map((n) => n.length);
@override
String get nodeId => 'fake-node';
@override
Future<CrdtChangeset> getChangeset() async => {};
@override
Future<void> merge(CrdtChangeset changeset) async {}
@override
Future<void> close() async {
await _controller.close();
}
}

View File

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

View File

@ -50,10 +50,15 @@ void main() {
calls++; calls++;
// Pending on the first two polls, then success. // Pending on the first two polls, then success.
if (calls < 3) { if (calls < 3) {
return http.Response(jsonEncode({'error': 'authorization_pending'}), 200); return http.Response(
jsonEncode({'error': 'authorization_pending'}),
200,
);
} }
return http.Response( 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); final token = await authWith(client).pollForToken(_device);
@ -67,7 +72,9 @@ void main() {
calls++; calls++;
if (calls == 1) { if (calls == 1) {
return http.Response( 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); return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200);
}); });
@ -77,13 +84,72 @@ void main() {
}); });
test('pollForToken throws on access_denied', () async { test('pollForToken throws on access_denied', () async {
final client = MockClient((req) async => http.Response( final client = MockClient(
jsonEncode({'error': 'access_denied', 'error_description': 'no'}), 200)); (req) async => http.Response(
jsonEncode({'error': 'access_denied', 'error_description': 'no'}),
200,
),
);
expect( expect(
() => authWith(client).pollForToken(_device), () => authWith(client).pollForToken(_device),
throwsA(isA<DeviceAuthException>() throwsA(
.having((e) => e.code, 'code', 'access_denied')), isA<DeviceAuthException>().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<DeviceAuthException>()),
);
});
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<DeviceAuthException>().having(
(e) => e.code,
'code',
'expired_token',
),
),
); );
}); });
} }

View File

@ -1,19 +1,30 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.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.dart';
import 'package:todo/data/note_repository.dart'; import 'package:todo/data/note_repository.dart';
void main() { void main() {
setUpAll(sqfliteFfiInit); 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(); final now = DateTime.now();
return Note( return Note(
id: id, id: id,
text: text, text: text,
priority: priority, priority: priority,
createdAt: now, status: status,
updatedAt: now, createdAt: createdAt ?? now,
updatedAt: updatedAt ?? now,
); );
} }
@ -58,4 +69,306 @@ void main() {
expect(notes.first.text, 'high'); expect(notes.first.text, 'high');
expect(notes.last.text, 'low'); 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');
});
} }

109
test/note_test.dart Normal file
View File

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

View File

@ -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<FakeNoteRepository> pumpList(
WidgetTester tester, {
List<Note> 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<Badge>(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<Badge>(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);
});
}

View File

@ -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 = '''
<!-- todo-backlog v1 -->
<!-- @note priority="bogus" status="" -->
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 = '''
<!-- todo-backlog v1 -->
Some preamble a user typed that is not a note.
<!-- @note id="x" priority="medium" status="todo" -->
real note
''';
final parsed = NotesMarkdown.parse(content);
expect(parsed.map((n) => n.text), ['real note']);
});
}

View File

@ -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<XFile?> openFile({
List<XTypeGroup>? 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<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> launchUrl(String url, LaunchOptions options) async {
launched = url;
return true;
}
}
void main() {
Future<FakeNoteRepository> pumpSettings(
WidgetTester tester, {
SyncSettings initial = const SyncSettings(
owner: 'kuhyx',
repo: 'todo-sync',
token: 't',
),
http.Client? httpClient,
List<Note> 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<void>.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<SyncSettings>(
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
});
}

152
test/sync_service_test.dart Normal file
View File

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

View File

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

View File

@ -25,11 +25,8 @@ Future<void> main() async {
// Throwaway directory so we never pollute the real `changesets/`. // Throwaway directory so we never pollute the real `changesets/`.
const service = SyncService(changesetDir: 'changesets_smoketest'); const service = SyncService(changesetDir: 'changesets_smoketest');
GitHubClient client() => GitHubClient( GitHubClient client() =>
owner: 'kuhyx', GitHubClient(owner: 'kuhyx', repo: 'todo-sync', token: token);
repo: 'todo-sync',
token: token,
);
final deviceA = await NoteRepository.openInMemory(); final deviceA = await NoteRepository.openInMemory();
final deviceB = await NoteRepository.openInMemory(); final deviceB = await NoteRepository.openInMemory();
@ -57,7 +54,8 @@ Future<void> main() async {
'Idea from device A @ $stamp', 'Idea from device A @ $stamp',
'Idea from device B @ $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. // Cleanup: remove the throwaway changeset files.
final cleanup = client(); final cleanup = client();
@ -73,7 +71,9 @@ Future<void> main() async {
await deviceB.close(); await deviceB.close();
if (converged) { 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); exit(0);
} else { } else {
stdout.writeln('\n❌ FAIL: devices did not converge. Expected $expected.'); stdout.writeln('\n❌ FAIL: devices did not converge. Expected $expected.');
@ -83,11 +83,14 @@ Future<void> main() async {
Future<void> _insert(NoteRepository repo, String text) async { Future<void> _insert(NoteRepository repo, String text) async {
final now = DateTime.now(); final now = DateTime.now();
await repo.upsert(Note( await repo.upsert(
Note(
id: '${now.microsecondsSinceEpoch}-${text.hashCode}', id: '${now.microsecondsSinceEpoch}-${text.hashCode}',
text: text, text: text,
priority: Priority.none, priority: Priority.medium,
status: Status.todo,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
)); ),
);
} }