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/profile
/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.
///
/// Stored as the integer [value] so ordering is trivial in SQL.
/// Every note always has a priority (there is no "none"); [medium] is the
/// default. Stored as the integer [value] so ordering is trivial in SQL.
enum Priority {
none(0),
low(1),
medium(2),
high(3);
low(1, 'Low'),
medium(2, 'Medium'),
high(3, 'High');
const Priority(this.value);
const Priority(this.value, this.label);
/// The default applied to new notes and to any legacy/unknown value.
static const Priority defaultValue = Priority.medium;
/// Integer persisted in the database; higher means more important.
final int value;
/// Rebuilds a [Priority] from its stored [value], defaulting to [none]
/// for any unknown/legacy value so reads never throw.
/// Human-readable label for UI controls (pickers, filters, list rows).
final String label;
/// Rebuilds a [Priority] from its stored [value], defaulting to
/// [defaultValue] for any unknown/legacy value (e.g. the old `0` = none)
/// so reads never throw and pre-existing notes show as Medium.
static Priority fromValue(int? value) {
return Priority.values.firstWhere(
(p) => p.value == value,
orElse: () => Priority.none,
orElse: () => defaultValue,
);
}
}
/// Workflow state of a note, independent of its [Priority].
///
/// Stored as the integer [value]. [todo] is the default (0) so existing
/// notes created before this field existed read back as "to do".
enum Status {
todo(0, 'To do'),
inProgress(1, 'In progress'),
done(2, 'Done'),
abandoned(3, 'Abandoned');
const Status(this.value, this.label);
/// Integer persisted in the database.
final int value;
/// Human-readable label for UI controls.
final String label;
/// Rebuilds a [Status] from its stored [value], defaulting to [todo]
/// for any unknown/legacy value so reads never throw.
static Status fromValue(int? value) {
return Status.values.firstWhere(
(s) => s.value == value,
orElse: () => Status.todo,
);
}
}
@ -36,6 +71,7 @@ class Note {
required this.id,
required this.text,
required this.priority,
required this.status,
required this.createdAt,
required this.updatedAt,
});
@ -49,6 +85,9 @@ class Note {
/// Priority tier for sorting/filtering.
final Priority priority;
/// Workflow state (to do / in progress / done / abandoned).
final Status status;
/// When the note was first created (set once, never changed).
final DateTime createdAt;
@ -64,17 +103,24 @@ class Note {
id: row['id'] as String,
text: (row['text'] as String?) ?? '',
priority: Priority.fromValue(row['priority'] as int?),
status: Status.fromValue(row['status'] as int?),
createdAt: DateTime.parse(row['created_at'] as String),
updatedAt: DateTime.parse(row['updated_at'] as String),
);
}
/// Returns a copy with selected fields replaced.
Note copyWith({String? text, Priority? priority, DateTime? updatedAt}) {
Note copyWith({
String? text,
Priority? priority,
Status? status,
DateTime? updatedAt,
}) {
return Note(
id: id,
text: text ?? this.text,
priority: priority ?? this.priority,
status: status ?? this.status,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);

View File

@ -3,11 +3,27 @@ import 'package:sqlite_crdt/sqlite_crdt.dart';
import 'note.dart';
/// How the history list should be ordered.
enum NoteSort {
createdDesc,
modifiedDesc,
alphabetical,
priorityDesc,
enum NoteSort { createdDesc, modifiedDesc, alphabetical, priorityDesc }
/// Summary of an [NoteRepository.importNotes] run, for user feedback.
class ImportOutcome {
const ImportOutcome({
required this.added,
required this.updated,
required this.skipped,
});
/// Notes that did not exist locally and were created.
final int added;
/// Existing notes overwritten because the import was newer.
final int updated;
/// Notes skipped because the local copy was the same age or newer.
final int skipped;
/// Total notes considered in the import.
int get total => added + updated + skipped;
}
/// Local-first persistence for [Note]s, backed by a CRDT SQLite database.
@ -29,22 +45,9 @@ class NoteRepository {
static Future<NoteRepository> open(String path) async {
final crdt = await SqliteCrdt.open(
path,
version: 1,
onCreate: (db, version) async {
// Plain columns only; the CRDT layer adds its own bookkeeping
// columns transparently. ISO-8601 strings keep timestamps both
// human-readable and lexicographically sortable.
await db.execute('''
CREATE TABLE notes (
id TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (id)
)
''');
},
version: _schemaVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
return NoteRepository._(crdt);
}
@ -52,23 +55,52 @@ class NoteRepository {
/// Opens a transient in-memory database; intended for tests.
static Future<NoteRepository> openInMemory() async {
final crdt = await SqliteCrdt.openInMemory(
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes (
id TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (id)
)
''');
},
version: _schemaVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
return NoteRepository._(crdt);
}
/// Current schema version. Bump when adding columns and add the matching
/// branch to [_onUpgrade] so existing on-device databases migrate.
static const int _schemaVersion = 3;
/// Creates the schema for a brand-new database. Plain columns only; the
/// CRDT layer adds its own bookkeeping columns transparently. ISO-8601
/// strings keep timestamps human-readable and lexicographically sortable.
static Future<void> _onCreate(CrdtTableExecutor db, int version) async {
await db.execute('''
CREATE TABLE notes (
id TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 2,
status INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (id)
)
''');
}
/// 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',
);
}
if (from < 3) {
await db.execute('UPDATE notes SET priority = 2 WHERE priority = 0');
}
}
/// Inserts a new note or updates the existing one with the same [id].
///
/// This is the single write path used by the capture screen's
@ -76,17 +108,19 @@ class NoteRepository {
Future<void> upsert(Note note) async {
await _crdt.execute(
'''
INSERT INTO notes (id, text, priority, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
INSERT INTO notes (id, text, priority, status, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (id) DO UPDATE SET
text = ?2,
priority = ?3,
updated_at = ?5
status = ?4,
updated_at = ?6
''',
[
note.id,
note.text,
note.priority.value,
note.status.value,
note.createdAt.toIso8601String(),
note.updatedAt.toIso8601String(),
],
@ -99,25 +133,71 @@ class NoteRepository {
await _crdt.execute('DELETE FROM notes WHERE id = ?1', [id]);
}
/// Returns all live notes ordered by [sort].
/// Merges [incoming] notes (e.g. from an imported file) into local storage.
///
/// Safe by design it never destroys a *newer* local edit: an incoming
/// note overwrites the local one only when its [Note.updatedAt] is strictly
/// newer, or when the id is not present locally. This makes re-importing a
/// stale backup a no-op for notes you've since edited, upholding the
/// "never lose ideas" guarantee. Notes absent from [incoming] are kept.
Future<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({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) async {
final rows = await _crdt
.query('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}');
final (where, args) = _buildWhere(filter);
final rows = await _crdt.query(
'SELECT * FROM notes WHERE $where ${_orderBy(sort)}',
args,
);
return rows.map(Note.fromRow).toList();
}
/// Emits the ordered note list and re-emits whenever the table changes,
/// so the UI can stay in sync without manual refreshes.
/// Emits the matching, ordered note list and re-emits whenever the table
/// changes, so the UI can stay in sync without manual refreshes.
///
/// [watch] takes an args *builder* (`() => args`); the captured [args]
/// list is immutable for this call because [filter] is immutable, so the
/// builder is safe to re-invoke.
Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) {
final (where, args) = _buildWhere(filter);
return _crdt
.watch('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}')
.watch('SELECT * FROM notes WHERE $where ${_orderBy(sort)}', () => args)
.map((rows) => rows.map(Note.fromRow).toList());
}
/// Emits the live count of non-deleted notes. Cheaper than [watchNotes]
/// for a header badge: SQLite computes the count without materialising or
/// parsing any rows, so per-keystroke autosave doesn't churn the UI.
Stream<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
/// in the sync repo so two devices never write the same file.
String get nodeId => _crdt.nodeId;
@ -146,4 +226,166 @@ class NoteRepository {
return 'ORDER BY priority DESC, updated_at DESC';
}
}
/// Builds the parameterised WHERE clause for [filter].
///
/// Returns the clause body (always rooted at `is_deleted = 0` so
/// tombstones stay hidden) and the positional argument list. All user
/// input is bound as parameters never string-interpolated so the
/// query is injection-safe. Date bounds use ISO-8601 string comparison,
/// which is valid because the stored timestamps are fixed-width and
/// lexicographically ordered.
(String, List<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 'package:flutter/foundation.dart';

View File

@ -38,11 +38,11 @@ class GitHubClient {
required String token,
http.Client? httpClient,
this.branch = 'main',
}) // Dart forbids private named params, so this can't be an initializing
// formal; assign it explicitly.
// ignore: prefer_initializing_formals
: _token = token,
_http = httpClient ?? http.Client();
}) // Dart forbids private named params, so this can't be an initializing
// formal; assign it explicitly.
// ignore: prefer_initializing_formals
: _token = token,
_http = httpClient ?? http.Client();
final String owner;
final String repo;
@ -53,11 +53,11 @@ class GitHubClient {
static const _apiBase = 'https://api.github.com';
Map<String, String> get _headers => {
'Authorization': 'Bearer $_token',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'todo-app-sync',
};
'Authorization': 'Bearer $_token',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'todo-app-sync',
};
Uri _contentsUri(String path) =>
Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path');
@ -76,11 +76,13 @@ class GitHubClient {
return decoded
.cast<Map<String, dynamic>>()
.where((e) => e['type'] == 'file')
.map((e) => GitHubFile(
name: e['name'] as String,
path: e['path'] as String,
sha: e['sha'] as String,
))
.map(
(e) => GitHubFile(
name: e['name'] as String,
path: e['path'] as String,
sha: e['sha'] as String,
),
)
.toList();
}
@ -148,7 +150,10 @@ class GitHubClient {
void _ensureOk(http.Response res, String action) {
if (res.statusCode < 200 || res.statusCode >= 300) {
throw GitHubApiException(res.statusCode, 'Failed to $action: ${res.body}');
throw GitHubApiException(
res.statusCode,
'Failed to $action: ${res.body}',
);
}
}
}

View File

@ -68,9 +68,9 @@ class GitHubDeviceAuth {
this.scope = 'repo',
http.Client? httpClient,
Future<void> Function(Duration)? delay,
}) : _http = httpClient ?? http.Client(),
// Indirection so tests can skip real waiting between polls.
_delay = delay ?? Future<void>.delayed;
}) : _http = httpClient ?? http.Client(),
// Indirection so tests can skip real waiting between polls.
_delay = delay ?? Future<void>.delayed;
final String clientId;
@ -82,8 +82,7 @@ class GitHubDeviceAuth {
static const _deviceCodeUrl = 'https://github.com/login/device/code';
static const _tokenUrl = 'https://github.com/login/oauth/access_token';
static const _grantType =
'urn:ietf:params:oauth:grant-type:device_code';
static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code';
/// Step 1: ask GitHub for a device + user code.
Future<DeviceCodeResponse> requestDeviceCode() async {
@ -96,7 +95,8 @@ class GitHubDeviceAuth {
throw DeviceAuthException('http_${res.statusCode}', res.body);
}
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.
@ -132,7 +132,9 @@ class GitHubDeviceAuth {
intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5;
case final String error:
throw DeviceAuthException(
error, (json['error_description'] as String?) ?? error);
error,
(json['error_description'] as String?) ?? error,
);
case null:
throw DeviceAuthException('unknown', 'Unexpected response: $json');
}

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.
class SyncResult {
const SyncResult({
required this.mergedDevices,
required this.pushed,
});
const SyncResult({required this.mergedDevices, required this.pushed});
/// How many other devices' changesets were pulled and merged.
final int mergedDevices;

View File

@ -28,6 +28,29 @@ class CaptureScreen extends StatefulWidget {
class _CaptureScreenState extends State<CaptureScreen> {
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 FocusNode _focusNode = FocusNode();
@ -37,6 +60,11 @@ class _CaptureScreenState extends State<CaptureScreen> {
DateTime? _draftCreatedAt;
DateTime? _lastSavedAt;
/// Priority/status applied to the current draft. Chosen before or during
/// typing; persisted on the first keystroke and on every later change.
Priority _draftPriority = Priority.defaultValue;
Status _draftStatus = Status.todo;
final SyncService _syncService = const SyncService();
SyncSettings? _settings;
bool _syncing = false;
@ -44,11 +72,29 @@ class _CaptureScreenState extends State<CaptureScreen> {
@override
void initState() {
super.initState();
_resetToTemplate();
SyncSettings.load().then((s) {
if (mounted) setState(() => _settings = s);
});
}
/// Loads the blank template into the field with the title placeholder
/// selected, so typing immediately overwrites it. Setting the controller
/// value programmatically does not fire [_onChanged], so this never
/// persists a note on its own only a real edit does.
void _resetToTemplate() {
_controller.value = const TextEditingValue(
text: _template,
selection: TextSelection(
baseOffset: 0,
extentOffset: _titlePlaceholder.length,
),
);
}
/// Whether [text] is still the untouched scaffold (nothing worth saving).
bool _isPristine(String text) => text.trim() == _template.trim();
@override
void dispose() {
_controller.dispose();
@ -62,7 +108,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
if (!mounted) return;
final result = await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute(
builder: (_) => SettingsScreen(initial: current),
builder: (_) =>
SettingsScreen(initial: current, repository: widget.repository),
),
);
if (result != null && mounted) setState(() => _settings = result);
@ -112,7 +159,9 @@ class _CaptureScreenState extends State<CaptureScreen> {
/// the first non-empty keystroke so empty drafts never hit storage.
Future<void> _onChanged(String text) async {
if (_draftId == null) {
if (text.isEmpty) return;
// Don't persist an empty field or the untouched template scaffold —
// a note is only created once the user actually fills something in.
if (text.isEmpty || _isPristine(text)) return;
_draftId = _uuid.v4();
_draftCreatedAt = DateTime.now();
}
@ -121,7 +170,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
Note(
id: _draftId!,
text: text,
priority: Priority.none,
priority: _draftPriority,
status: _draftStatus,
createdAt: _draftCreatedAt!,
updatedAt: now,
),
@ -129,17 +179,51 @@ class _CaptureScreenState extends State<CaptureScreen> {
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() {
final hadText = _controller.text.trim().isNotEmpty;
// A note was actually persisted only if a draft row was created.
final saved = _draftId != null;
setState(() {
_controller.clear();
_resetToTemplate();
_draftId = null;
_draftCreatedAt = null;
_lastSavedAt = null;
_draftPriority = Priority.defaultValue;
_draftStatus = Status.todo;
});
_focusNode.requestFocus();
if (hadText) {
if (saved) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Idea saved locally'),
@ -157,10 +241,10 @@ class _CaptureScreenState extends State<CaptureScreen> {
title: const Text('Capture'),
actions: [
// Live count of stored notes, proving local persistence.
StreamBuilder<List<Note>>(
stream: widget.repository.watchNotes(),
StreamBuilder<int>(
stream: widget.repository.watchCount(),
builder: (context, snapshot) {
final count = snapshot.data?.length ?? 0;
final count = snapshot.data ?? 0;
return Padding(
padding: const EdgeInsets.only(right: 4),
child: Center(child: Text('$count saved')),
@ -195,6 +279,32 @@ class _CaptureScreenState extends State<CaptureScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Pickers sit above the editor so the bottom-right Save FAB
// never overlaps them.
Row(
children: [
Expanded(
child: _MetaDropdown<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(
child: TextField(
controller: _controller,
@ -213,11 +323,15 @@ class _CaptureScreenState extends State<CaptureScreen> {
),
),
const SizedBox(height: 8),
Text(
_lastSavedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${_formatTime(_lastSavedAt!)}',
style: theme.textTheme.bodySmall,
// Leave room so the Save FAB doesn't cover the save indicator.
Padding(
padding: const EdgeInsets.only(right: 96),
child: Text(
_lastSavedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${_formatTime(_lastSavedAt!)}',
style: theme.textTheme.bodySmall,
),
),
],
),
@ -236,3 +350,49 @@ class _CaptureScreenState extends State<CaptureScreen> {
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,50 +1,263 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../data/note.dart';
import '../data/note_repository.dart';
/// Barebones list of all stored/synced notes, newest-modified first.
/// The default status selection: hide completed/dropped work. This is the
/// app's notion of "unfiltered", so it does not count towards the filter
/// badge and is what "Clear all" resets to.
const Set<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
/// created/modified/alphabetical/priority is deferred). Its job today is to
/// show that synced items actually landed locally.
class NotesListScreen extends StatelessWidget {
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
/// only owns transient view state ([NoteSort] + [NoteFilter]) and rebuilds
/// the watch stream when that state changes. The stream is memoised so a
/// rebuild (e.g. a search keystroke) does not churn a new DB subscription.
class NotesListScreen extends StatefulWidget {
const NotesListScreen({required this.repository, super.key});
final NoteRepository repository;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Notes')),
body: StreamBuilder<List<Note>>(
stream: repository.watchNotes(),
builder: (context, snapshot) {
final notes = snapshot.data ?? const <Note>[];
if (notes.isEmpty) {
return const Center(child: Text('No notes yet'));
}
return ListView.separated(
itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final note = notes[i];
final firstLine = note.text.split('\n').first;
return ListTile(
title: Text(
firstLine.isEmpty ? '(empty)' : firstLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text('edited ${_relative(note.updatedAt)}'),
);
},
);
State<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
Widget build(BuildContext context) {
final badgeCount = _badgeCount;
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),
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) {
final notes = snapshot.data ?? const <Note>[];
if (notes.isEmpty) {
return Center(
child: Text(
_filter.isEmpty
? 'No notes yet'
: 'No notes match these filters',
),
);
}
return ListView.separated(
itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => _NoteTile(
note: notes[i],
onTap: () => _openNoteActions(notes[i]),
),
);
},
),
),
],
),
);
}
}
/// One row in the notes list: first line, then a metadata subtitle.
class _NoteTile extends StatelessWidget {
const _NoteTile({required this.note, required this.onTap});
final Note note;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final firstLine = note.text.split('\n').first;
// Every note has a status and a priority now, so both are always shown.
final meta = <String>[
note.status.label,
note.priority.label,
'edited ${_relative(note.updatedAt)}',
].join(' · ');
return ListTile(
title: Text(
firstLine.isEmpty ? '(empty)' : firstLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(meta),
trailing: const Icon(Icons.more_vert),
onTap: onTap,
);
}
/// Compact relative time like "2m ago" for the list subtitle.
String _relative(DateTime t) {
final d = DateTime.now().difference(t);
@ -54,3 +267,389 @@ class NotesListScreen extends StatelessWidget {
return '${d.inDays}d ago';
}
}
/// Bottom sheet for editing one note's priority/status or deleting it.
class _NoteActionsSheet extends StatefulWidget {
const _NoteActionsSheet({
required this.note,
required this.onChanged,
required this.onDelete,
});
final Note note;
final Future<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/services.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/note_repository.dart';
import '../sync/github_client.dart';
import '../sync/github_device_auth.dart';
import '../sync/notes_markdown.dart';
import '../sync/sync_settings.dart';
/// Settings screen for GitHub sync configuration.
/// Settings screen for GitHub sync configuration and note backup.
///
/// Primary path: the "Connect GitHub" button runs the OAuth **device flow**
/// (authorize in a browser, no token pasting). The manual token field
/// remains as a fallback.
/// Primary sync path: the "Connect GitHub" button runs the OAuth **device
/// flow** (authorize in a browser, no token pasting). The manual token field
/// remains as a fallback. The Backup section exports/imports all notes as a
/// single Markdown file (see [NotesMarkdown]).
class SettingsScreen extends StatefulWidget {
const SettingsScreen({required this.initial, super.key});
const SettingsScreen({
required this.initial,
required this.repository,
this.httpClient,
super.key,
});
final SyncSettings initial;
final NoteRepository repository;
/// Optional HTTP client for the GitHub calls (test-connection and device
/// flow). Injected by tests; production uses each client's default.
final http.Client? httpClient;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late final TextEditingController _owner =
TextEditingController(text: widget.initial.owner);
late final TextEditingController _repo =
TextEditingController(text: widget.initial.repo);
late final TextEditingController _token =
TextEditingController(text: widget.initial.token);
late final TextEditingController _clientId =
TextEditingController(text: widget.initial.clientId);
late final TextEditingController _owner = TextEditingController(
text: widget.initial.owner,
);
late final TextEditingController _repo = TextEditingController(
text: widget.initial.repo,
);
late final TextEditingController _token = TextEditingController(
text: widget.initial.token,
);
late final TextEditingController _clientId = TextEditingController(
text: widget.initial.clientId,
);
bool _testing = false;
String? _status;
@ -43,11 +66,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
SyncSettings get _current => SyncSettings(
owner: _owner.text.trim(),
repo: _repo.text.trim(),
token: _token.text.trim(),
clientId: _clientId.text.trim(),
);
owner: _owner.text.trim(),
repo: _repo.text.trim(),
token: _token.text.trim(),
clientId: _clientId.text.trim(),
);
/// Runs the OAuth device flow and, on success, fills in the token field.
Future<void> _connectGitHub() async {
@ -56,7 +79,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
setState(() => _status = 'Enter the OAuth App client id first.');
return;
}
final auth = GitHubDeviceAuth(clientId: clientId);
final auth = GitHubDeviceAuth(
clientId: clientId,
httpClient: widget.httpClient,
);
try {
final device = await auth.requestDeviceCode();
if (!mounted) return;
@ -85,12 +111,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
_status = null;
});
final s = _current;
final client = GitHubClient(owner: s.owner, repo: s.repo, token: s.token);
final client = GitHubClient(
owner: s.owner,
repo: s.repo,
token: s.token,
httpClient: widget.httpClient,
);
try {
final ok = await client.canAccessRepo();
setState(() => _status = ok
? 'Connected — repo is reachable.'
: 'Could not access ${s.owner}/${s.repo}. Check token scope.');
setState(
() => _status = ok
? 'Connected — repo is reachable.'
: 'Could not access ${s.owner}/${s.repo}. Check token scope.',
);
} catch (e) {
setState(() => _status = 'Error: $e');
} finally {
@ -105,6 +138,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
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
Widget build(BuildContext context) {
return Scaffold(
@ -128,8 +231,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const SizedBox(height: 24),
Text('Connect with GitHub',
style: Theme.of(context).textTheme.titleMedium),
Text(
'Connect with GitHub',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
TextField(
controller: _clientId,
@ -148,8 +253,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Text('Or paste a token (fallback)',
style: Theme.of(context).textTheme.titleMedium),
Text(
'Or paste a token (fallback)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
TextField(
controller: _token,
@ -182,6 +289,32 @@ class _SettingsScreenState extends State<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) ...[
const SizedBox(height: 16),
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),

View File

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

View File

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

View File

@ -73,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.3"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@ -113,6 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@ -121,6 +137,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector:
dependency: "direct main"
description:
name: file_selector
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
url: "https://pub.dev"
source: hosted
version: "1.1.0"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b"
url: "https://pub.dev"
source: hosted
version: "0.5.2+8"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
url: "https://pub.dev"
source: hosted
version: "0.5.3+5"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: "direct dev"
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_web:
dependency: transitive
description:
name: file_selector_web
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
@ -264,6 +344,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
@ -353,7 +441,7 @@ packages:
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
dependency: "direct dev"
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
@ -392,6 +480,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
url: "https://pub.dev"
source: hosted
version: "13.1.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
shared_preferences:
dependency: "direct main"
description:
@ -614,7 +718,7 @@ packages:
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
dependency: "direct dev"
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
@ -669,6 +773,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "6.3.0"
xdg_directories:
dependency: transitive
description:

View File

@ -42,7 +42,8 @@ dependencies:
http: ^1.6.0
shared_preferences: ^2.5.5
url_launcher: ^6.3.2
share_plus: ^13.1.0
file_selector: ^1.1.0
dev_dependencies:
flutter_test:
sdk: flutter
@ -54,6 +55,12 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^6.0.0
# Plugin platform interfaces, depended on directly so tests can swap in
# fakes for the file picker and URL launcher (see settings_screen_test.dart).
file_selector_platform_interface: ^2.7.0
url_launcher_platform_interface: ^2.3.2
plugin_platform_interface: ^2.1.8
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

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

@ -7,10 +7,10 @@ import 'package:todo/sync/github_device_auth.dart';
/// Builds an auth instance whose polls resolve instantly (no real waiting).
GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth(
clientId: 'test-client-id',
httpClient: client,
delay: (_) => Future<void>.value(),
);
clientId: 'test-client-id',
httpClient: client,
delay: (_) => Future<void>.value(),
);
const _device = DeviceCodeResponse(
deviceCode: 'dev-123',
@ -50,10 +50,15 @@ void main() {
calls++;
// Pending on the first two polls, then success.
if (calls < 3) {
return http.Response(jsonEncode({'error': 'authorization_pending'}), 200);
return http.Response(
jsonEncode({'error': 'authorization_pending'}),
200,
);
}
return http.Response(
jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}), 200);
jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}),
200,
);
});
final token = await authWith(client).pollForToken(_device);
@ -67,7 +72,9 @@ void main() {
calls++;
if (calls == 1) {
return http.Response(
jsonEncode({'error': 'slow_down', 'interval': 1}), 200);
jsonEncode({'error': 'slow_down', 'interval': 1}),
200,
);
}
return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200);
});
@ -77,13 +84,72 @@ void main() {
});
test('pollForToken throws on access_denied', () async {
final client = MockClient((req) async => http.Response(
jsonEncode({'error': 'access_denied', 'error_description': 'no'}), 200));
final client = MockClient(
(req) async => http.Response(
jsonEncode({'error': 'access_denied', 'error_description': 'no'}),
200,
),
);
expect(
() => authWith(client).pollForToken(_device),
throwsA(isA<DeviceAuthException>()
.having((e) => e.code, 'code', 'access_denied')),
throwsA(
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:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqlite_crdt/sqlite_crdt.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
void main() {
setUpAll(sqfliteFfiInit);
Note note(String id, String text, {Priority priority = Priority.none}) {
Note note(
String id,
String text, {
Priority priority = Priority.medium,
Status status = Status.todo,
DateTime? createdAt,
DateTime? updatedAt,
}) {
final now = DateTime.now();
return Note(
id: id,
text: text,
priority: priority,
createdAt: now,
updatedAt: now,
status: status,
createdAt: createdAt ?? now,
updatedAt: updatedAt ?? now,
);
}
@ -58,4 +69,306 @@ void main() {
expect(notes.first.text, 'high');
expect(notes.last.text, 'low');
});
group('text search', () {
test('matches a case-insensitive substring', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(note('a', 'Buy MILK and eggs'));
await repo.upsert(note('b', 'call the dentist'));
final notes = await repo.listNotes(
filter: const NoteFilter(query: 'milk'),
);
expect(notes.map((n) => n.id), ['a']);
});
test('escapes LIKE wildcards so % is matched literally', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(note('pct', 'a%b'));
await repo.upsert(note('plain', 'axb'));
// Without escaping, 'a%b' as a LIKE pattern would also match 'axb'.
final notes = await repo.listNotes(
filter: const NoteFilter(query: 'a%b'),
);
expect(notes.map((n) => n.id), ['pct']);
});
});
group('attribute filters', () {
test('priority filter includes only the selected priorities', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(note('lo', 'l', priority: Priority.low));
await repo.upsert(note('me', 'm', priority: Priority.medium));
await repo.upsert(note('hi', 'h', priority: Priority.high));
final notes = await repo.listNotes(
filter: const NoteFilter(priorities: {Priority.low, Priority.high}),
);
expect(notes.map((n) => n.id).toSet(), {'lo', 'hi'});
});
test('status filter includes only the selected statuses', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(note('t', 'todo', status: Status.todo));
await repo.upsert(note('d', 'done', status: Status.done));
await repo.upsert(note('x', 'gone', status: Status.abandoned));
final notes = await repo.listNotes(
filter: const NoteFilter(statuses: {Status.todo, Status.inProgress}),
);
expect(notes.map((n) => n.id), ['t']);
});
});
group('date range filters', () {
final jan = DateTime(2026, 1, 15, 10);
final jun = DateTime(2026, 6, 15, 10);
test('created range bounds are inclusive by calendar day', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(note('j', 'jan', createdAt: jan, updatedAt: jan));
await repo.upsert(note('u', 'jun', createdAt: jun, updatedAt: jun));
// A single-day range on Jan 15 includes the 10:00 note that day.
final notes = await repo.listNotes(
filter: NoteFilter(
createdFrom: DateTime(2026, 1, 15),
createdTo: DateTime(2026, 1, 15),
),
);
expect(notes.map((n) => n.id), ['j']);
});
test('created and updated ranges apply independently', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
// Created in Jan, but last updated in Jun.
await repo.upsert(
note('e', 'edited later', createdAt: jan, updatedAt: jun),
);
// Matches on the updated range...
final byUpdated = await repo.listNotes(
filter: NoteFilter(
updatedFrom: DateTime(2026, 6, 1),
updatedTo: DateTime(2026, 6, 30),
),
);
expect(byUpdated.map((n) => n.id), ['e']);
// ...but not when the created range excludes January.
final byCreated = await repo.listNotes(
filter: NoteFilter(
createdFrom: DateTime(2026, 6, 1),
createdTo: DateTime(2026, 6, 30),
),
);
expect(byCreated, isEmpty);
});
});
test('filters combine with AND', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(
note(
'match',
'urgent report',
priority: Priority.high,
status: Status.inProgress,
),
);
await repo.upsert(
note(
'wrongPrio',
'urgent report',
priority: Priority.low,
status: Status.inProgress,
),
);
await repo.upsert(
note(
'wrongText',
'casual note',
priority: Priority.high,
status: Status.inProgress,
),
);
final notes = await repo.listNotes(
filter: const NoteFilter(
query: 'urgent',
priorities: {Priority.high},
statuses: {Status.inProgress},
),
);
expect(notes.map((n) => n.id), ['match']);
});
group('priority defaults', () {
test('fromValue maps legacy/unknown values to medium', () {
expect(Priority.fromValue(0), Priority.medium); // old "none"
expect(Priority.fromValue(null), Priority.medium);
expect(Priority.fromValue(99), Priority.medium);
// Known values still round-trip.
expect(Priority.fromValue(1), Priority.low);
expect(Priority.fromValue(2), Priority.medium);
expect(Priority.fromValue(3), Priority.high);
});
});
group('importNotes (safe merge)', () {
test('adds notes whose id is not present locally', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(note('a', 'local'));
final outcome = await repo.importNotes([note('b', 'incoming')]);
expect(outcome.added, 1);
expect(outcome.updated, 0);
expect(outcome.skipped, 0);
expect((await repo.listNotes()).map((n) => n.id).toSet(), {'a', 'b'});
});
test('overwrites a local note only when the import is newer', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
final old = DateTime(2026, 1, 1);
final newer = DateTime(2026, 6, 1);
await repo.upsert(note('a', 'local-old', updatedAt: old));
final outcome = await repo.importNotes([
note('a', 'imported-new', updatedAt: newer),
]);
expect(outcome.updated, 1);
final stored = (await repo.listNotes()).single;
expect(stored.text, 'imported-new');
});
test('never clobbers a newer local edit with a stale import', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
final stale = DateTime(2026, 1, 1);
final fresh = DateTime(2026, 6, 1);
// Local note is the freshly-edited one.
await repo.upsert(note('a', 'local-fresh', updatedAt: fresh));
final outcome = await repo.importNotes([
note('a', 'backup-stale', updatedAt: stale),
]);
expect(outcome.skipped, 1);
expect(outcome.updated, 0);
// The newer local edit survives "never lose ideas".
expect((await repo.listNotes()).single.text, 'local-fresh');
});
});
test('v2→v3 migration backfills priority 0 to medium', () async {
final dir = await Directory.systemTemp.createTemp('todo_migration');
final path = '${dir.path}/notes.db';
addTearDown(() => dir.delete(recursive: true));
// Build a v2 database (status column present, no priority backfill) and
// insert a legacy note with the old priority 0 ("none").
final v2 = await SqliteCrdt.open(
path,
version: 2,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes (
id TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (id)
)
''');
},
);
final now = DateTime.now().toIso8601String();
await v2.execute(
'INSERT INTO notes (id, text, priority, status, created_at, updated_at) '
'VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
['legacy', 'old idea', 0, 0, now, now],
);
await v2.close();
// Reopening through the repository runs onUpgrade to v3.
final repo = await NoteRepository.open(path);
addTearDown(repo.close);
final notes = await repo.listNotes();
expect(notes.single.id, 'legacy');
expect(notes.single.priority, Priority.medium);
});
group('sorting and streams', () {
test('createdDesc and alphabetical orderings', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
final t1 = DateTime(2026, 1, 1);
final t2 = DateTime(2026, 2, 1);
await repo.upsert(note('a', 'banana', createdAt: t1, updatedAt: t1));
await repo.upsert(note('b', 'apple', createdAt: t2, updatedAt: t2));
final byCreated = await repo.listNotes(sort: NoteSort.createdDesc);
expect(byCreated.first.id, 'b'); // newest created first
final alpha = await repo.listNotes(sort: NoteSort.alphabetical);
expect(alpha.map((n) => n.text), ['apple', 'banana']);
});
test('watchNotes and watchCount emit current state', () async {
final repo = await NoteRepository.openInMemory();
addTearDown(repo.close);
await repo.upsert(note('a', 'one'));
expect(await repo.watchNotes().first, hasLength(1));
expect(await repo.watchCount().first, 1);
});
});
test('nodeId, changeset merge and close', () async {
// Use file-backed DBs: two openInMemory repos would share one `:memory:`
// connection, so they cannot model two independent devices.
final dir = await Directory.systemTemp.createTemp('todo_merge');
addTearDown(() => dir.delete(recursive: true));
final source = await NoteRepository.open('${dir.path}/source.db');
final target = await NoteRepository.open('${dir.path}/target.db');
addTearDown(target.close);
expect(source.nodeId, isNotEmpty);
await source.upsert(note('a', 'shared idea'));
final changeset = await source.getChangeset();
await source.close();
// getChangeset serialises hlc/modified as Strings and returns read-only
// rows; merge expects mutable maps with Hlc objects. Rebuild them the
// way the sync layer does after its JSON round-trip.
final revived = {
for (final entry in changeset.entries)
entry.key: [
for (final record in entry.value)
{
...record,
'hlc': Hlc.parse(record['hlc'] as String),
'modified': Hlc.parse(record['modified'] as String),
},
],
};
await target.merge(revived);
final merged = await target.listNotes();
expect(merged.single.text, 'shared idea');
});
}

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