mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:23:15 +02:00
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:
parent
7f51e95396
commit
7f84414c87
7
.gitignore
vendored
7
.gitignore
vendored
@ -43,3 +43,10 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Exported personal notes — never commit (contains private idea content)
|
||||||
|
BACKLOG.md
|
||||||
|
todo-backlog.md
|
||||||
|
|
||||||
|
# Android Gradle build output
|
||||||
|
/android/build/
|
||||||
|
|||||||
@ -8,24 +8,59 @@ library;
|
|||||||
|
|
||||||
/// Priority tier for a note, used for sorting and visual grouping.
|
/// Priority tier for a note, used for sorting and visual grouping.
|
||||||
///
|
///
|
||||||
/// Stored as the integer [value] so ordering is trivial in SQL.
|
/// Every note always has a priority (there is no "none"); [medium] is the
|
||||||
|
/// default. Stored as the integer [value] so ordering is trivial in SQL.
|
||||||
enum Priority {
|
enum Priority {
|
||||||
none(0),
|
low(1, 'Low'),
|
||||||
low(1),
|
medium(2, 'Medium'),
|
||||||
medium(2),
|
high(3, 'High');
|
||||||
high(3);
|
|
||||||
|
|
||||||
const Priority(this.value);
|
const Priority(this.value, this.label);
|
||||||
|
|
||||||
|
/// The default applied to new notes and to any legacy/unknown value.
|
||||||
|
static const Priority defaultValue = Priority.medium;
|
||||||
|
|
||||||
/// Integer persisted in the database; higher means more important.
|
/// Integer persisted in the database; higher means more important.
|
||||||
final int value;
|
final int value;
|
||||||
|
|
||||||
/// Rebuilds a [Priority] from its stored [value], defaulting to [none]
|
/// Human-readable label for UI controls (pickers, filters, list rows).
|
||||||
/// for any unknown/legacy value so reads never throw.
|
final String label;
|
||||||
|
|
||||||
|
/// Rebuilds a [Priority] from its stored [value], defaulting to
|
||||||
|
/// [defaultValue] for any unknown/legacy value (e.g. the old `0` = none)
|
||||||
|
/// so reads never throw and pre-existing notes show as Medium.
|
||||||
static Priority fromValue(int? value) {
|
static Priority fromValue(int? value) {
|
||||||
return Priority.values.firstWhere(
|
return Priority.values.firstWhere(
|
||||||
(p) => p.value == value,
|
(p) => p.value == value,
|
||||||
orElse: () => Priority.none,
|
orElse: () => defaultValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Workflow state of a note, independent of its [Priority].
|
||||||
|
///
|
||||||
|
/// Stored as the integer [value]. [todo] is the default (0) so existing
|
||||||
|
/// notes created before this field existed read back as "to do".
|
||||||
|
enum Status {
|
||||||
|
todo(0, 'To do'),
|
||||||
|
inProgress(1, 'In progress'),
|
||||||
|
done(2, 'Done'),
|
||||||
|
abandoned(3, 'Abandoned');
|
||||||
|
|
||||||
|
const Status(this.value, this.label);
|
||||||
|
|
||||||
|
/// Integer persisted in the database.
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
/// Human-readable label for UI controls.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Rebuilds a [Status] from its stored [value], defaulting to [todo]
|
||||||
|
/// for any unknown/legacy value so reads never throw.
|
||||||
|
static Status fromValue(int? value) {
|
||||||
|
return Status.values.firstWhere(
|
||||||
|
(s) => s.value == value,
|
||||||
|
orElse: () => Status.todo,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -36,6 +71,7 @@ class Note {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.text,
|
required this.text,
|
||||||
required this.priority,
|
required this.priority,
|
||||||
|
required this.status,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
});
|
});
|
||||||
@ -49,6 +85,9 @@ class Note {
|
|||||||
/// Priority tier for sorting/filtering.
|
/// Priority tier for sorting/filtering.
|
||||||
final Priority priority;
|
final Priority priority;
|
||||||
|
|
||||||
|
/// Workflow state (to do / in progress / done / abandoned).
|
||||||
|
final Status status;
|
||||||
|
|
||||||
/// When the note was first created (set once, never changed).
|
/// When the note was first created (set once, never changed).
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
|
||||||
@ -64,17 +103,24 @@ class Note {
|
|||||||
id: row['id'] as String,
|
id: row['id'] as String,
|
||||||
text: (row['text'] as String?) ?? '',
|
text: (row['text'] as String?) ?? '',
|
||||||
priority: Priority.fromValue(row['priority'] as int?),
|
priority: Priority.fromValue(row['priority'] as int?),
|
||||||
|
status: Status.fromValue(row['status'] as int?),
|
||||||
createdAt: DateTime.parse(row['created_at'] as String),
|
createdAt: DateTime.parse(row['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a copy with selected fields replaced.
|
/// Returns a copy with selected fields replaced.
|
||||||
Note copyWith({String? text, Priority? priority, DateTime? updatedAt}) {
|
Note copyWith({
|
||||||
|
String? text,
|
||||||
|
Priority? priority,
|
||||||
|
Status? status,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
return Note(
|
return Note(
|
||||||
id: id,
|
id: id,
|
||||||
text: text ?? this.text,
|
text: text ?? this.text,
|
||||||
priority: priority ?? this.priority,
|
priority: priority ?? this.priority,
|
||||||
|
status: status ?? this.status,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,11 +3,27 @@ import 'package:sqlite_crdt/sqlite_crdt.dart';
|
|||||||
import 'note.dart';
|
import 'note.dart';
|
||||||
|
|
||||||
/// How the history list should be ordered.
|
/// How the history list should be ordered.
|
||||||
enum NoteSort {
|
enum NoteSort { createdDesc, modifiedDesc, alphabetical, priorityDesc }
|
||||||
createdDesc,
|
|
||||||
modifiedDesc,
|
/// Summary of an [NoteRepository.importNotes] run, for user feedback.
|
||||||
alphabetical,
|
class ImportOutcome {
|
||||||
priorityDesc,
|
const ImportOutcome({
|
||||||
|
required this.added,
|
||||||
|
required this.updated,
|
||||||
|
required this.skipped,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Notes that did not exist locally and were created.
|
||||||
|
final int added;
|
||||||
|
|
||||||
|
/// Existing notes overwritten because the import was newer.
|
||||||
|
final int updated;
|
||||||
|
|
||||||
|
/// Notes skipped because the local copy was the same age or newer.
|
||||||
|
final int skipped;
|
||||||
|
|
||||||
|
/// Total notes considered in the import.
|
||||||
|
int get total => added + updated + skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local-first persistence for [Note]s, backed by a CRDT SQLite database.
|
/// Local-first persistence for [Note]s, backed by a CRDT SQLite database.
|
||||||
@ -29,22 +45,9 @@ class NoteRepository {
|
|||||||
static Future<NoteRepository> open(String path) async {
|
static Future<NoteRepository> open(String path) async {
|
||||||
final crdt = await SqliteCrdt.open(
|
final crdt = await SqliteCrdt.open(
|
||||||
path,
|
path,
|
||||||
version: 1,
|
version: _schemaVersion,
|
||||||
onCreate: (db, version) async {
|
onCreate: _onCreate,
|
||||||
// Plain columns only; the CRDT layer adds its own bookkeeping
|
onUpgrade: _onUpgrade,
|
||||||
// columns transparently. ISO-8601 strings keep timestamps both
|
|
||||||
// human-readable and lexicographically sortable.
|
|
||||||
await db.execute('''
|
|
||||||
CREATE TABLE notes (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
text TEXT NOT NULL DEFAULT '',
|
|
||||||
priority INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
)
|
|
||||||
''');
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return NoteRepository._(crdt);
|
return NoteRepository._(crdt);
|
||||||
}
|
}
|
||||||
@ -52,23 +55,52 @@ class NoteRepository {
|
|||||||
/// Opens a transient in-memory database; intended for tests.
|
/// Opens a transient in-memory database; intended for tests.
|
||||||
static Future<NoteRepository> openInMemory() async {
|
static Future<NoteRepository> openInMemory() async {
|
||||||
final crdt = await SqliteCrdt.openInMemory(
|
final crdt = await SqliteCrdt.openInMemory(
|
||||||
version: 1,
|
version: _schemaVersion,
|
||||||
onCreate: (db, version) async {
|
onCreate: _onCreate,
|
||||||
await db.execute('''
|
onUpgrade: _onUpgrade,
|
||||||
CREATE TABLE notes (
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
text TEXT NOT NULL DEFAULT '',
|
|
||||||
priority INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (id)
|
|
||||||
)
|
|
||||||
''');
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return NoteRepository._(crdt);
|
return NoteRepository._(crdt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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].
|
/// 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
|
/// This is the single write path used by the capture screen's
|
||||||
@ -76,17 +108,19 @@ class NoteRepository {
|
|||||||
Future<void> upsert(Note note) async {
|
Future<void> upsert(Note note) async {
|
||||||
await _crdt.execute(
|
await _crdt.execute(
|
||||||
'''
|
'''
|
||||||
INSERT INTO notes (id, text, priority, created_at, updated_at)
|
INSERT INTO notes (id, text, priority, status, created_at, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
text = ?2,
|
text = ?2,
|
||||||
priority = ?3,
|
priority = ?3,
|
||||||
updated_at = ?5
|
status = ?4,
|
||||||
|
updated_at = ?6
|
||||||
''',
|
''',
|
||||||
[
|
[
|
||||||
note.id,
|
note.id,
|
||||||
note.text,
|
note.text,
|
||||||
note.priority.value,
|
note.priority.value,
|
||||||
|
note.status.value,
|
||||||
note.createdAt.toIso8601String(),
|
note.createdAt.toIso8601String(),
|
||||||
note.updatedAt.toIso8601String(),
|
note.updatedAt.toIso8601String(),
|
||||||
],
|
],
|
||||||
@ -99,25 +133,71 @@ class NoteRepository {
|
|||||||
await _crdt.execute('DELETE FROM notes WHERE id = ?1', [id]);
|
await _crdt.execute('DELETE FROM notes WHERE id = ?1', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all live notes ordered by [sort].
|
/// Merges [incoming] notes (e.g. from an imported file) into local storage.
|
||||||
|
///
|
||||||
|
/// Safe by design — it never destroys a *newer* local edit: an incoming
|
||||||
|
/// note overwrites the local one only when its [Note.updatedAt] is strictly
|
||||||
|
/// newer, or when the id is not present locally. This makes re-importing a
|
||||||
|
/// stale backup a no-op for notes you've since edited, upholding the
|
||||||
|
/// "never lose ideas" guarantee. Notes absent from [incoming] are kept.
|
||||||
|
Future<ImportOutcome> importNotes(List<Note> incoming) async {
|
||||||
|
final existing = {for (final n in await listNotes()) n.id: n};
|
||||||
|
var added = 0;
|
||||||
|
var updated = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
for (final note in incoming) {
|
||||||
|
final local = existing[note.id];
|
||||||
|
if (local == null) {
|
||||||
|
await upsert(note);
|
||||||
|
added++;
|
||||||
|
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
||||||
|
await upsert(note);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ImportOutcome(added: added, updated: updated, skipped: skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the live notes matching [filter], ordered by [sort].
|
||||||
Future<List<Note>> listNotes({
|
Future<List<Note>> listNotes({
|
||||||
NoteSort sort = NoteSort.modifiedDesc,
|
NoteSort sort = NoteSort.modifiedDesc,
|
||||||
|
NoteFilter filter = const NoteFilter(),
|
||||||
}) async {
|
}) async {
|
||||||
final rows = await _crdt
|
final (where, args) = _buildWhere(filter);
|
||||||
.query('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}');
|
final rows = await _crdt.query(
|
||||||
|
'SELECT * FROM notes WHERE $where ${_orderBy(sort)}',
|
||||||
|
args,
|
||||||
|
);
|
||||||
return rows.map(Note.fromRow).toList();
|
return rows.map(Note.fromRow).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emits the ordered note list and re-emits whenever the table changes,
|
/// Emits the matching, ordered note list and re-emits whenever the table
|
||||||
/// so the UI can stay in sync without manual refreshes.
|
/// changes, so the UI can stay in sync without manual refreshes.
|
||||||
|
///
|
||||||
|
/// [watch] takes an args *builder* (`() => args`); the captured [args]
|
||||||
|
/// list is immutable for this call because [filter] is immutable, so the
|
||||||
|
/// builder is safe to re-invoke.
|
||||||
Stream<List<Note>> watchNotes({
|
Stream<List<Note>> watchNotes({
|
||||||
NoteSort sort = NoteSort.modifiedDesc,
|
NoteSort sort = NoteSort.modifiedDesc,
|
||||||
|
NoteFilter filter = const NoteFilter(),
|
||||||
}) {
|
}) {
|
||||||
|
final (where, args) = _buildWhere(filter);
|
||||||
return _crdt
|
return _crdt
|
||||||
.watch('SELECT * FROM notes WHERE is_deleted = 0 ${_orderBy(sort)}')
|
.watch('SELECT * FROM notes WHERE $where ${_orderBy(sort)}', () => args)
|
||||||
.map((rows) => rows.map(Note.fromRow).toList());
|
.map((rows) => rows.map(Note.fromRow).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits the live count of non-deleted notes. Cheaper than [watchNotes]
|
||||||
|
/// for a header badge: SQLite computes the count without materialising or
|
||||||
|
/// parsing any rows, so per-keystroke autosave doesn't churn the UI.
|
||||||
|
Stream<int> watchCount() {
|
||||||
|
return _crdt
|
||||||
|
.watch('SELECT COUNT(*) AS c FROM notes WHERE is_deleted = 0')
|
||||||
|
.map((rows) => (rows.first['c'] as int?) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
/// This device's stable CRDT node id. Used to name its changeset file
|
/// This device's stable CRDT node id. Used to name its changeset file
|
||||||
/// in the sync repo so two devices never write the same file.
|
/// in the sync repo so two devices never write the same file.
|
||||||
String get nodeId => _crdt.nodeId;
|
String get nodeId => _crdt.nodeId;
|
||||||
@ -146,4 +226,166 @@ class NoteRepository {
|
|||||||
return 'ORDER BY priority DESC, updated_at DESC';
|
return 'ORDER BY priority DESC, updated_at DESC';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the parameterised WHERE clause for [filter].
|
||||||
|
///
|
||||||
|
/// Returns the clause body (always rooted at `is_deleted = 0` so
|
||||||
|
/// tombstones stay hidden) and the positional argument list. All user
|
||||||
|
/// input is bound as parameters — never string-interpolated — so the
|
||||||
|
/// query is injection-safe. Date bounds use ISO-8601 string comparison,
|
||||||
|
/// which is valid because the stored timestamps are fixed-width and
|
||||||
|
/// lexicographically ordered.
|
||||||
|
(String, List<Object?>) _buildWhere(NoteFilter filter) {
|
||||||
|
final clauses = <String>['is_deleted = 0'];
|
||||||
|
final args = <Object?>[];
|
||||||
|
|
||||||
|
final query = filter.query.trim();
|
||||||
|
if (query.isNotEmpty) {
|
||||||
|
// Escape LIKE wildcards in the user's text so a literal '%' or '_'
|
||||||
|
// matches itself. LIKE is ASCII-case-insensitive by default.
|
||||||
|
final escaped = query
|
||||||
|
.replaceAll(r'\', r'\\')
|
||||||
|
.replaceAll('%', r'\%')
|
||||||
|
.replaceAll('_', r'\_');
|
||||||
|
clauses.add(r"text LIKE ? ESCAPE '\'");
|
||||||
|
args.add('%$escaped%');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.priorities.isNotEmpty) {
|
||||||
|
final placeholders = List.filled(
|
||||||
|
filter.priorities.length,
|
||||||
|
'?',
|
||||||
|
).join(', ');
|
||||||
|
clauses.add('priority IN ($placeholders)');
|
||||||
|
args.addAll(filter.priorities.map((p) => p.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.statuses.isNotEmpty) {
|
||||||
|
final placeholders = List.filled(filter.statuses.length, '?').join(', ');
|
||||||
|
clauses.add('status IN ($placeholders)');
|
||||||
|
args.addAll(filter.statuses.map((s) => s.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
_addDateBounds(
|
||||||
|
clauses,
|
||||||
|
args,
|
||||||
|
'created_at',
|
||||||
|
filter.createdFrom,
|
||||||
|
filter.createdTo,
|
||||||
|
);
|
||||||
|
_addDateBounds(
|
||||||
|
clauses,
|
||||||
|
args,
|
||||||
|
'updated_at',
|
||||||
|
filter.updatedFrom,
|
||||||
|
filter.updatedTo,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (clauses.join(' AND '), args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends inclusive day-granularity bounds for [column] to [clauses].
|
||||||
|
///
|
||||||
|
/// [from]/[to] are treated as whole calendar days: `from` includes its
|
||||||
|
/// entire day (compared from 00:00:00) and `to` includes its entire day
|
||||||
|
/// (compared with `< to+1 day`), matching how a user reads a date range.
|
||||||
|
void _addDateBounds(
|
||||||
|
List<String> clauses,
|
||||||
|
List<Object?> args,
|
||||||
|
String column,
|
||||||
|
DateTime? from,
|
||||||
|
DateTime? to,
|
||||||
|
) {
|
||||||
|
if (from != null) {
|
||||||
|
clauses.add('$column >= ?');
|
||||||
|
args.add(_startOfDay(from).toIso8601String());
|
||||||
|
}
|
||||||
|
if (to != null) {
|
||||||
|
clauses.add('$column < ?');
|
||||||
|
args.add(_startOfDay(to).add(const Duration(days: 1)).toIso8601String());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Midnight (local) of [t]'s calendar day.
|
||||||
|
static DateTime _startOfDay(DateTime t) => DateTime(t.year, t.month, t.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An immutable set of constraints for querying notes.
|
||||||
|
///
|
||||||
|
/// All fields combine with logical AND. Empty/null fields impose no
|
||||||
|
/// constraint. Lives in the data layer so the SQL it drives never leaks
|
||||||
|
/// into the UI. Construct copies with [copyWith] when toggling one facet.
|
||||||
|
class NoteFilter {
|
||||||
|
const NoteFilter({
|
||||||
|
this.query = '',
|
||||||
|
this.priorities = const {},
|
||||||
|
this.statuses = const {},
|
||||||
|
this.createdFrom,
|
||||||
|
this.createdTo,
|
||||||
|
this.updatedFrom,
|
||||||
|
this.updatedTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Case-insensitive substring matched against the note body.
|
||||||
|
final String query;
|
||||||
|
|
||||||
|
/// Notes must have one of these priorities. Empty means "any priority".
|
||||||
|
final Set<Priority> priorities;
|
||||||
|
|
||||||
|
/// Notes must have one of these statuses. Empty means "any status".
|
||||||
|
final Set<Status> statuses;
|
||||||
|
|
||||||
|
/// Inclusive lower/upper bounds (by calendar day) on the creation date.
|
||||||
|
final DateTime? createdFrom;
|
||||||
|
final DateTime? createdTo;
|
||||||
|
|
||||||
|
/// Inclusive lower/upper bounds (by calendar day) on the last-updated date.
|
||||||
|
final DateTime? updatedFrom;
|
||||||
|
final DateTime? updatedTo;
|
||||||
|
|
||||||
|
/// True when no constraint is active (the unfiltered, full list).
|
||||||
|
bool get isEmpty =>
|
||||||
|
query.trim().isEmpty &&
|
||||||
|
priorities.isEmpty &&
|
||||||
|
statuses.isEmpty &&
|
||||||
|
createdFrom == null &&
|
||||||
|
createdTo == null &&
|
||||||
|
updatedFrom == null &&
|
||||||
|
updatedTo == null;
|
||||||
|
|
||||||
|
/// Number of distinct active facets, for an "N filters" badge in the UI.
|
||||||
|
int get activeCount {
|
||||||
|
var n = 0;
|
||||||
|
if (query.trim().isNotEmpty) n++;
|
||||||
|
if (priorities.isNotEmpty) n++;
|
||||||
|
if (statuses.isNotEmpty) n++;
|
||||||
|
if (createdFrom != null || createdTo != null) n++;
|
||||||
|
if (updatedFrom != null || updatedTo != null) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with selected facets replaced. A `null` argument keeps
|
||||||
|
/// the current value; clearing a date is done via the dedicated [clear]
|
||||||
|
/// flags so `null` can mean "unchanged".
|
||||||
|
NoteFilter copyWith({
|
||||||
|
String? query,
|
||||||
|
Set<Priority>? priorities,
|
||||||
|
Set<Status>? statuses,
|
||||||
|
DateTime? createdFrom,
|
||||||
|
DateTime? createdTo,
|
||||||
|
DateTime? updatedFrom,
|
||||||
|
DateTime? updatedTo,
|
||||||
|
bool clearCreated = false,
|
||||||
|
bool clearUpdated = false,
|
||||||
|
}) {
|
||||||
|
return NoteFilter(
|
||||||
|
query: query ?? this.query,
|
||||||
|
priorities: priorities ?? this.priorities,
|
||||||
|
statuses: statuses ?? this.statuses,
|
||||||
|
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
||||||
|
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
||||||
|
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
|
||||||
|
updatedTo: clearUpdated ? null : (updatedTo ?? this.updatedTo),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// App bootstrap: wires platform DB paths (path_provider) into the repository
|
||||||
|
// and calls runApp. Exercised end-to-end by running the app, not unit tests.
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|||||||
@ -38,11 +38,11 @@ class GitHubClient {
|
|||||||
required String token,
|
required String token,
|
||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
this.branch = 'main',
|
this.branch = 'main',
|
||||||
}) // Dart forbids private named params, so this can't be an initializing
|
}) // Dart forbids private named params, so this can't be an initializing
|
||||||
// formal; assign it explicitly.
|
// formal; assign it explicitly.
|
||||||
// ignore: prefer_initializing_formals
|
// ignore: prefer_initializing_formals
|
||||||
: _token = token,
|
: _token = token,
|
||||||
_http = httpClient ?? http.Client();
|
_http = httpClient ?? http.Client();
|
||||||
|
|
||||||
final String owner;
|
final String owner;
|
||||||
final String repo;
|
final String repo;
|
||||||
@ -53,11 +53,11 @@ class GitHubClient {
|
|||||||
static const _apiBase = 'https://api.github.com';
|
static const _apiBase = 'https://api.github.com';
|
||||||
|
|
||||||
Map<String, String> get _headers => {
|
Map<String, String> get _headers => {
|
||||||
'Authorization': 'Bearer $_token',
|
'Authorization': 'Bearer $_token',
|
||||||
'Accept': 'application/vnd.github+json',
|
'Accept': 'application/vnd.github+json',
|
||||||
'X-GitHub-Api-Version': '2022-11-28',
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
'User-Agent': 'todo-app-sync',
|
'User-Agent': 'todo-app-sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
Uri _contentsUri(String path) =>
|
Uri _contentsUri(String path) =>
|
||||||
Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path');
|
Uri.parse('$_apiBase/repos/$owner/$repo/contents/$path');
|
||||||
@ -76,11 +76,13 @@ class GitHubClient {
|
|||||||
return decoded
|
return decoded
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
.where((e) => e['type'] == 'file')
|
.where((e) => e['type'] == 'file')
|
||||||
.map((e) => GitHubFile(
|
.map(
|
||||||
name: e['name'] as String,
|
(e) => GitHubFile(
|
||||||
path: e['path'] as String,
|
name: e['name'] as String,
|
||||||
sha: e['sha'] as String,
|
path: e['path'] as String,
|
||||||
))
|
sha: e['sha'] as String,
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,7 +150,10 @@ class GitHubClient {
|
|||||||
|
|
||||||
void _ensureOk(http.Response res, String action) {
|
void _ensureOk(http.Response res, String action) {
|
||||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
throw GitHubApiException(res.statusCode, 'Failed to $action: ${res.body}');
|
throw GitHubApiException(
|
||||||
|
res.statusCode,
|
||||||
|
'Failed to $action: ${res.body}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,9 +68,9 @@ class GitHubDeviceAuth {
|
|||||||
this.scope = 'repo',
|
this.scope = 'repo',
|
||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
Future<void> Function(Duration)? delay,
|
Future<void> Function(Duration)? delay,
|
||||||
}) : _http = httpClient ?? http.Client(),
|
}) : _http = httpClient ?? http.Client(),
|
||||||
// Indirection so tests can skip real waiting between polls.
|
// Indirection so tests can skip real waiting between polls.
|
||||||
_delay = delay ?? Future<void>.delayed;
|
_delay = delay ?? Future<void>.delayed;
|
||||||
|
|
||||||
final String clientId;
|
final String clientId;
|
||||||
|
|
||||||
@ -82,8 +82,7 @@ class GitHubDeviceAuth {
|
|||||||
|
|
||||||
static const _deviceCodeUrl = 'https://github.com/login/device/code';
|
static const _deviceCodeUrl = 'https://github.com/login/device/code';
|
||||||
static const _tokenUrl = 'https://github.com/login/oauth/access_token';
|
static const _tokenUrl = 'https://github.com/login/oauth/access_token';
|
||||||
static const _grantType =
|
static const _grantType = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
'urn:ietf:params:oauth:grant-type:device_code';
|
|
||||||
|
|
||||||
/// Step 1: ask GitHub for a device + user code.
|
/// Step 1: ask GitHub for a device + user code.
|
||||||
Future<DeviceCodeResponse> requestDeviceCode() async {
|
Future<DeviceCodeResponse> requestDeviceCode() async {
|
||||||
@ -96,7 +95,8 @@ class GitHubDeviceAuth {
|
|||||||
throw DeviceAuthException('http_${res.statusCode}', res.body);
|
throw DeviceAuthException('http_${res.statusCode}', res.body);
|
||||||
}
|
}
|
||||||
return DeviceCodeResponse.fromJson(
|
return DeviceCodeResponse.fromJson(
|
||||||
jsonDecode(res.body) as Map<String, dynamic>);
|
jsonDecode(res.body) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step 2: poll until the user authorizes, returning the access token.
|
/// Step 2: poll until the user authorizes, returning the access token.
|
||||||
@ -132,7 +132,9 @@ class GitHubDeviceAuth {
|
|||||||
intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5;
|
intervalSeconds = (json['interval'] as int?) ?? intervalSeconds + 5;
|
||||||
case final String error:
|
case final String error:
|
||||||
throw DeviceAuthException(
|
throw DeviceAuthException(
|
||||||
error, (json['error_description'] as String?) ?? error);
|
error,
|
||||||
|
(json['error_description'] as String?) ?? error,
|
||||||
|
);
|
||||||
case null:
|
case null:
|
||||||
throw DeviceAuthException('unknown', 'Unexpected response: $json');
|
throw DeviceAuthException('unknown', 'Unexpected response: $json');
|
||||||
}
|
}
|
||||||
|
|||||||
103
lib/sync/notes_markdown.dart
Normal file
103
lib/sync/notes_markdown.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,10 +7,7 @@ import 'github_client.dart';
|
|||||||
|
|
||||||
/// Outcome of a sync run, for surfacing in the UI.
|
/// Outcome of a sync run, for surfacing in the UI.
|
||||||
class SyncResult {
|
class SyncResult {
|
||||||
const SyncResult({
|
const SyncResult({required this.mergedDevices, required this.pushed});
|
||||||
required this.mergedDevices,
|
|
||||||
required this.pushed,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// How many other devices' changesets were pulled and merged.
|
/// How many other devices' changesets were pulled and merged.
|
||||||
final int mergedDevices;
|
final int mergedDevices;
|
||||||
|
|||||||
@ -28,6 +28,29 @@ class CaptureScreen extends StatefulWidget {
|
|||||||
class _CaptureScreenState extends State<CaptureScreen> {
|
class _CaptureScreenState extends State<CaptureScreen> {
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// Placeholder for the note's title line; selected on reset so the first
|
||||||
|
/// keystroke replaces it.
|
||||||
|
static const _titlePlaceholder = '<imperative title>';
|
||||||
|
|
||||||
|
/// The structured scaffold pre-filled into every new note (see the
|
||||||
|
/// `<work_backlog>` format). Pre-filling beats a hint because the em-dashes
|
||||||
|
/// and labels are tedious to type on mobile — the user just fills the gaps.
|
||||||
|
static const _template =
|
||||||
|
'$_titlePlaceholder\n'
|
||||||
|
'\n'
|
||||||
|
'what — \n'
|
||||||
|
'where — \n'
|
||||||
|
'must —\n'
|
||||||
|
'- \n'
|
||||||
|
'nice —\n'
|
||||||
|
'- \n'
|
||||||
|
'out —\n'
|
||||||
|
'- \n'
|
||||||
|
'done — \n'
|
||||||
|
'depends — \n'
|
||||||
|
'estimate — \n'
|
||||||
|
'refs — ';
|
||||||
|
|
||||||
final TextEditingController _controller = TextEditingController();
|
final TextEditingController _controller = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
|
||||||
@ -37,6 +60,11 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
DateTime? _draftCreatedAt;
|
DateTime? _draftCreatedAt;
|
||||||
DateTime? _lastSavedAt;
|
DateTime? _lastSavedAt;
|
||||||
|
|
||||||
|
/// Priority/status applied to the current draft. Chosen before or during
|
||||||
|
/// typing; persisted on the first keystroke and on every later change.
|
||||||
|
Priority _draftPriority = Priority.defaultValue;
|
||||||
|
Status _draftStatus = Status.todo;
|
||||||
|
|
||||||
final SyncService _syncService = const SyncService();
|
final SyncService _syncService = const SyncService();
|
||||||
SyncSettings? _settings;
|
SyncSettings? _settings;
|
||||||
bool _syncing = false;
|
bool _syncing = false;
|
||||||
@ -44,11 +72,29 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_resetToTemplate();
|
||||||
SyncSettings.load().then((s) {
|
SyncSettings.load().then((s) {
|
||||||
if (mounted) setState(() => _settings = s);
|
if (mounted) setState(() => _settings = s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads the blank template into the field with the title placeholder
|
||||||
|
/// selected, so typing immediately overwrites it. Setting the controller
|
||||||
|
/// value programmatically does not fire [_onChanged], so this never
|
||||||
|
/// persists a note on its own — only a real edit does.
|
||||||
|
void _resetToTemplate() {
|
||||||
|
_controller.value = const TextEditingValue(
|
||||||
|
text: _template,
|
||||||
|
selection: TextSelection(
|
||||||
|
baseOffset: 0,
|
||||||
|
extentOffset: _titlePlaceholder.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [text] is still the untouched scaffold (nothing worth saving).
|
||||||
|
bool _isPristine(String text) => text.trim() == _template.trim();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
@ -62,7 +108,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final result = await Navigator.of(context).push<SyncSettings>(
|
final result = await Navigator.of(context).push<SyncSettings>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => SettingsScreen(initial: current),
|
builder: (_) =>
|
||||||
|
SettingsScreen(initial: current, repository: widget.repository),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result != null && mounted) setState(() => _settings = result);
|
if (result != null && mounted) setState(() => _settings = result);
|
||||||
@ -112,7 +159,9 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
/// the first non-empty keystroke so empty drafts never hit storage.
|
/// the first non-empty keystroke so empty drafts never hit storage.
|
||||||
Future<void> _onChanged(String text) async {
|
Future<void> _onChanged(String text) async {
|
||||||
if (_draftId == null) {
|
if (_draftId == null) {
|
||||||
if (text.isEmpty) return;
|
// Don't persist an empty field or the untouched template scaffold —
|
||||||
|
// a note is only created once the user actually fills something in.
|
||||||
|
if (text.isEmpty || _isPristine(text)) return;
|
||||||
_draftId = _uuid.v4();
|
_draftId = _uuid.v4();
|
||||||
_draftCreatedAt = DateTime.now();
|
_draftCreatedAt = DateTime.now();
|
||||||
}
|
}
|
||||||
@ -121,7 +170,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
Note(
|
Note(
|
||||||
id: _draftId!,
|
id: _draftId!,
|
||||||
text: text,
|
text: text,
|
||||||
priority: Priority.none,
|
priority: _draftPriority,
|
||||||
|
status: _draftStatus,
|
||||||
createdAt: _draftCreatedAt!,
|
createdAt: _draftCreatedAt!,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
@ -129,17 +179,51 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
if (mounted) setState(() => _lastSavedAt = now);
|
if (mounted) setState(() => _lastSavedAt = now);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finalises the current idea and resets the field for the next one.
|
/// Applies a new priority to the draft, persisting immediately if a note
|
||||||
|
/// row already exists (otherwise it is applied on the first keystroke).
|
||||||
|
Future<void> _setPriority(Priority priority) async {
|
||||||
|
setState(() => _draftPriority = priority);
|
||||||
|
await _persistDraftMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a new status to the draft, persisting immediately if a note
|
||||||
|
/// row already exists.
|
||||||
|
Future<void> _setStatus(Status status) async {
|
||||||
|
setState(() => _draftStatus = status);
|
||||||
|
await _persistDraftMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-saves the draft's metadata when only priority/status changed.
|
||||||
|
Future<void> _persistDraftMeta() async {
|
||||||
|
if (_draftId == null) return;
|
||||||
|
final now = DateTime.now();
|
||||||
|
await widget.repository.upsert(
|
||||||
|
Note(
|
||||||
|
id: _draftId!,
|
||||||
|
text: _controller.text,
|
||||||
|
priority: _draftPriority,
|
||||||
|
status: _draftStatus,
|
||||||
|
createdAt: _draftCreatedAt!,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _lastSavedAt = now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalises the current idea and resets the field to a fresh template.
|
||||||
void _saveAndReset() {
|
void _saveAndReset() {
|
||||||
final hadText = _controller.text.trim().isNotEmpty;
|
// A note was actually persisted only if a draft row was created.
|
||||||
|
final saved = _draftId != null;
|
||||||
setState(() {
|
setState(() {
|
||||||
_controller.clear();
|
_resetToTemplate();
|
||||||
_draftId = null;
|
_draftId = null;
|
||||||
_draftCreatedAt = null;
|
_draftCreatedAt = null;
|
||||||
_lastSavedAt = null;
|
_lastSavedAt = null;
|
||||||
|
_draftPriority = Priority.defaultValue;
|
||||||
|
_draftStatus = Status.todo;
|
||||||
});
|
});
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
if (hadText) {
|
if (saved) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Idea saved locally'),
|
content: Text('Idea saved locally'),
|
||||||
@ -157,10 +241,10 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
title: const Text('Capture'),
|
title: const Text('Capture'),
|
||||||
actions: [
|
actions: [
|
||||||
// Live count of stored notes, proving local persistence.
|
// Live count of stored notes, proving local persistence.
|
||||||
StreamBuilder<List<Note>>(
|
StreamBuilder<int>(
|
||||||
stream: widget.repository.watchNotes(),
|
stream: widget.repository.watchCount(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final count = snapshot.data?.length ?? 0;
|
final count = snapshot.data ?? 0;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 4),
|
padding: const EdgeInsets.only(right: 4),
|
||||||
child: Center(child: Text('$count saved')),
|
child: Center(child: Text('$count saved')),
|
||||||
@ -195,6 +279,32 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// Pickers sit above the editor so the bottom-right Save FAB
|
||||||
|
// never overlaps them.
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MetaDropdown<Priority>(
|
||||||
|
label: 'Priority',
|
||||||
|
value: _draftPriority,
|
||||||
|
values: Priority.values,
|
||||||
|
labelOf: (p) => p.label,
|
||||||
|
onChanged: _setPriority,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _MetaDropdown<Status>(
|
||||||
|
label: 'Status',
|
||||||
|
value: _draftStatus,
|
||||||
|
values: Status.values,
|
||||||
|
labelOf: (s) => s.label,
|
||||||
|
onChanged: _setStatus,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
@ -213,11 +323,15 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
// Leave room so the Save FAB doesn't cover the save indicator.
|
||||||
_lastSavedAt == null
|
Padding(
|
||||||
? 'Autosaves as you type'
|
padding: const EdgeInsets.only(right: 96),
|
||||||
: 'Saved locally at ${_formatTime(_lastSavedAt!)}',
|
child: Text(
|
||||||
style: theme.textTheme.bodySmall,
|
_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)}';
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,50 +1,263 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/note.dart';
|
import '../data/note.dart';
|
||||||
import '../data/note_repository.dart';
|
import '../data/note_repository.dart';
|
||||||
|
|
||||||
/// Barebones list of all stored/synced notes, newest-modified first.
|
/// The default status selection: hide completed/dropped work. This is the
|
||||||
|
/// app's notion of "unfiltered", so it does not count towards the filter
|
||||||
|
/// badge and is what "Clear all" resets to.
|
||||||
|
const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
|
||||||
|
|
||||||
|
/// Searchable, filterable, sortable list of stored/synced notes.
|
||||||
///
|
///
|
||||||
/// Deliberately minimal for now (the rich history view with filter/sort by
|
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
|
||||||
/// created/modified/alphabetical/priority is deferred). Its job today is to
|
/// only owns transient view state ([NoteSort] + [NoteFilter]) and rebuilds
|
||||||
/// show that synced items actually landed locally.
|
/// the watch stream when that state changes. The stream is memoised so a
|
||||||
class NotesListScreen extends StatelessWidget {
|
/// rebuild (e.g. a search keystroke) does not churn a new DB subscription.
|
||||||
|
class NotesListScreen extends StatefulWidget {
|
||||||
const NotesListScreen({required this.repository, super.key});
|
const NotesListScreen({required this.repository, super.key});
|
||||||
|
|
||||||
final NoteRepository repository;
|
final NoteRepository repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<NotesListScreen> createState() => _NotesListScreenState();
|
||||||
return Scaffold(
|
}
|
||||||
appBar: AppBar(title: const Text('Notes')),
|
|
||||||
body: StreamBuilder<List<Note>>(
|
class _NotesListScreenState extends State<NotesListScreen> {
|
||||||
stream: repository.watchNotes(),
|
/// How long to wait after the last keystroke before re-querying, so we
|
||||||
builder: (context, snapshot) {
|
/// don't spin up a new subscription on every character typed.
|
||||||
final notes = snapshot.data ?? const <Note>[];
|
static const _searchDebounce = Duration(milliseconds: 250);
|
||||||
if (notes.isEmpty) {
|
|
||||||
return const Center(child: Text('No notes yet'));
|
final TextEditingController _searchController = TextEditingController();
|
||||||
}
|
Timer? _debounce;
|
||||||
return ListView.separated(
|
|
||||||
itemCount: notes.length,
|
NoteSort _sort = NoteSort.modifiedDesc;
|
||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
||||||
itemBuilder: (context, i) {
|
/// Default view hides completed/dropped work: only To do + In progress.
|
||||||
final note = notes[i];
|
/// The user can widen this (or clear it) via the filter sheet.
|
||||||
final firstLine = note.text.split('\n').first;
|
NoteFilter _filter = const NoteFilter(statuses: kDefaultStatuses);
|
||||||
return ListTile(
|
|
||||||
title: Text(
|
/// Whether [statuses] is exactly the default selection (so the badge can
|
||||||
firstLine.isEmpty ? '(empty)' : firstLine,
|
/// treat the default view as "unfiltered").
|
||||||
maxLines: 2,
|
static bool _statusesAreDefault(Set<Status> statuses) =>
|
||||||
overflow: TextOverflow.ellipsis,
|
statuses.length == kDefaultStatuses.length &&
|
||||||
),
|
statuses.containsAll(kDefaultStatuses);
|
||||||
subtitle: Text('edited ${_relative(note.updatedAt)}'),
|
|
||||||
);
|
/// 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.
|
/// Compact relative time like "2m ago" for the list subtitle.
|
||||||
String _relative(DateTime t) {
|
String _relative(DateTime t) {
|
||||||
final d = DateTime.now().difference(t);
|
final d = DateTime.now().difference(t);
|
||||||
@ -54,3 +267,389 @@ class NotesListScreen extends StatelessWidget {
|
|||||||
return '${d.inDays}d ago';
|
return '${d.inDays}d ago';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bottom sheet for editing one note's priority/status or deleting it.
|
||||||
|
class _NoteActionsSheet extends StatefulWidget {
|
||||||
|
const _NoteActionsSheet({
|
||||||
|
required this.note,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Note note;
|
||||||
|
final Future<void> Function(Note) onChanged;
|
||||||
|
final Future<void> Function() onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_NoteActionsSheet> createState() => _NoteActionsSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteActionsSheetState extends State<_NoteActionsSheet> {
|
||||||
|
late Priority _priority = widget.note.priority;
|
||||||
|
late Status _status = widget.note.status;
|
||||||
|
|
||||||
|
Future<void> _persist() async {
|
||||||
|
await widget.onChanged(
|
||||||
|
widget.note.copyWith(
|
||||||
|
priority: _priority,
|
||||||
|
status: _status,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final firstLine = widget.note.text.split('\n').first;
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
firstLine.isEmpty ? '(empty)' : firstLine,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_EnumChips<Status>(
|
||||||
|
label: 'Status',
|
||||||
|
values: Status.values,
|
||||||
|
selected: {_status},
|
||||||
|
labelOf: (s) => s.label,
|
||||||
|
onSelected: (s) {
|
||||||
|
setState(() => _status = s);
|
||||||
|
_persist();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_EnumChips<Priority>(
|
||||||
|
label: 'Priority',
|
||||||
|
values: Priority.values,
|
||||||
|
selected: {_priority},
|
||||||
|
labelOf: (p) => p.label,
|
||||||
|
onSelected: (p) {
|
||||||
|
setState(() => _priority = p);
|
||||||
|
_persist();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: const Text('Delete note'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
await widget.onDelete();
|
||||||
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-select chip row for an enum (used for per-note priority/status).
|
||||||
|
class _EnumChips<T> extends StatelessWidget {
|
||||||
|
const _EnumChips({
|
||||||
|
required this.label,
|
||||||
|
required this.values,
|
||||||
|
required this.selected,
|
||||||
|
required this.labelOf,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final List<T> values;
|
||||||
|
final Set<T> selected;
|
||||||
|
final String Function(T) labelOf;
|
||||||
|
final ValueChanged<T> onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final v in values)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(labelOf(v)),
|
||||||
|
selected: selected.contains(v),
|
||||||
|
onSelected: (_) => onSelected(v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filter editing sheet: priority + status multi-select and Created /
|
||||||
|
/// Last-updated date ranges (presets + a custom range picker). Edits a
|
||||||
|
/// working copy and returns it via [Navigator.pop] on "Apply".
|
||||||
|
class _FilterSheet extends StatefulWidget {
|
||||||
|
const _FilterSheet({required this.initial});
|
||||||
|
|
||||||
|
final NoteFilter initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FilterSheet> createState() => _FilterSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterSheetState extends State<_FilterSheet> {
|
||||||
|
late Set<Priority> _priorities = {...widget.initial.priorities};
|
||||||
|
late Set<Status> _statuses = {...widget.initial.statuses};
|
||||||
|
late DateTime? _createdFrom = widget.initial.createdFrom;
|
||||||
|
late DateTime? _createdTo = widget.initial.createdTo;
|
||||||
|
late DateTime? _updatedFrom = widget.initial.updatedFrom;
|
||||||
|
late DateTime? _updatedTo = widget.initial.updatedTo;
|
||||||
|
|
||||||
|
void _toggle<T>(Set<T> set, T value) {
|
||||||
|
setState(() => set.contains(value) ? set.remove(value) : set.add(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearAll() {
|
||||||
|
setState(() {
|
||||||
|
_priorities = {};
|
||||||
|
// Reset to the default view (hide Done/Abandoned), not an empty set,
|
||||||
|
// so "Clear all" matches the app's unfiltered baseline.
|
||||||
|
_statuses = {...kDefaultStatuses};
|
||||||
|
_createdFrom = null;
|
||||||
|
_createdTo = null;
|
||||||
|
_updatedFrom = null;
|
||||||
|
_updatedTo = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteFilter _build() {
|
||||||
|
// query is owned by the search box and re-applied by the caller.
|
||||||
|
return NoteFilter(
|
||||||
|
priorities: _priorities,
|
||||||
|
statuses: _statuses,
|
||||||
|
createdFrom: _createdFrom,
|
||||||
|
createdTo: _createdTo,
|
||||||
|
updatedFrom: _updatedFrom,
|
||||||
|
updatedTo: _updatedTo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
16 + MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('Filters', style: theme.textTheme.titleLarge),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _clearAll,
|
||||||
|
child: const Text('Clear all'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_MultiChips<Status>(
|
||||||
|
label: 'Status',
|
||||||
|
values: Status.values,
|
||||||
|
selected: _statuses,
|
||||||
|
labelOf: (s) => s.label,
|
||||||
|
onToggle: (s) => _toggle(_statuses, s),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_MultiChips<Priority>(
|
||||||
|
label: 'Priority',
|
||||||
|
values: Priority.values,
|
||||||
|
selected: _priorities,
|
||||||
|
labelOf: (p) => p.label,
|
||||||
|
onToggle: (p) => _toggle(_priorities, p),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_DateRangeField(
|
||||||
|
label: 'Created',
|
||||||
|
from: _createdFrom,
|
||||||
|
to: _createdTo,
|
||||||
|
onChanged: (from, to) => setState(() {
|
||||||
|
_createdFrom = from;
|
||||||
|
_createdTo = to;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_DateRangeField(
|
||||||
|
label: 'Last updated',
|
||||||
|
from: _updatedFrom,
|
||||||
|
to: _updatedTo,
|
||||||
|
onChanged: (from, to) => setState(() {
|
||||||
|
_updatedFrom = from;
|
||||||
|
_updatedTo = to;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(_build()),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-select chip group for an enum (used by the filter sheet).
|
||||||
|
class _MultiChips<T> extends StatelessWidget {
|
||||||
|
const _MultiChips({
|
||||||
|
required this.label,
|
||||||
|
required this.values,
|
||||||
|
required this.selected,
|
||||||
|
required this.labelOf,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final List<T> values;
|
||||||
|
final Set<T> selected;
|
||||||
|
final String Function(T) labelOf;
|
||||||
|
final ValueChanged<T> onToggle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final v in values)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(labelOf(v)),
|
||||||
|
selected: selected.contains(v),
|
||||||
|
onSelected: (_) => onToggle(v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A date-range control offering quick presets plus a custom range picker.
|
||||||
|
///
|
||||||
|
/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to
|
||||||
|
/// the parent; `null`/`null` means "any date" for this field.
|
||||||
|
class _DateRangeField extends StatelessWidget {
|
||||||
|
const _DateRangeField({
|
||||||
|
required this.label,
|
||||||
|
required this.from,
|
||||||
|
required this.to,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final DateTime? from;
|
||||||
|
final DateTime? to;
|
||||||
|
|
||||||
|
/// Called with the new (from, to); either may be null to clear.
|
||||||
|
final void Function(DateTime? from, DateTime? to) onChanged;
|
||||||
|
|
||||||
|
bool get _hasRange => from != null || to != null;
|
||||||
|
|
||||||
|
/// Sets a preset range of the last [days] days ending today.
|
||||||
|
void _applyDays(int days) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
onChanged(today.subtract(Duration(days: days - 1)), today);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickCustom(BuildContext context) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final picked = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(now.year + 1, 12, 31),
|
||||||
|
initialDateRange: (from != null && to != null)
|
||||||
|
? DateTimeRange(start: from!, end: to!)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
if (picked != null) onChanged(picked.start, picked.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(label, style: theme.textTheme.labelLarge),
|
||||||
|
const Spacer(),
|
||||||
|
if (_hasRange)
|
||||||
|
Text(_rangeLabel(), style: theme.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
ActionChip(
|
||||||
|
label: const Text('Today'),
|
||||||
|
onPressed: () => _applyDays(1),
|
||||||
|
),
|
||||||
|
ActionChip(
|
||||||
|
label: const Text('7 days'),
|
||||||
|
onPressed: () => _applyDays(7),
|
||||||
|
),
|
||||||
|
ActionChip(
|
||||||
|
label: const Text('30 days'),
|
||||||
|
onPressed: () => _applyDays(30),
|
||||||
|
),
|
||||||
|
ActionChip(
|
||||||
|
label: const Text('Custom…'),
|
||||||
|
onPressed: () => _pickCustom(context),
|
||||||
|
),
|
||||||
|
if (_hasRange)
|
||||||
|
ActionChip(
|
||||||
|
avatar: const Icon(Icons.clear, size: 16),
|
||||||
|
label: const Text('Any'),
|
||||||
|
onPressed: () => onChanged(null, null),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact "YYYY-MM-DD → YYYY-MM-DD" (or one-sided) summary of the range.
|
||||||
|
String _rangeLabel() {
|
||||||
|
String d(DateTime? t) =>
|
||||||
|
t == null ? '…' : '${t.year}-${_two(t.month)}-${_two(t.day)}';
|
||||||
|
return '${d(from)} → ${d(to)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
}
|
||||||
|
|||||||
@ -1,34 +1,57 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../data/note_repository.dart';
|
||||||
import '../sync/github_client.dart';
|
import '../sync/github_client.dart';
|
||||||
import '../sync/github_device_auth.dart';
|
import '../sync/github_device_auth.dart';
|
||||||
|
import '../sync/notes_markdown.dart';
|
||||||
import '../sync/sync_settings.dart';
|
import '../sync/sync_settings.dart';
|
||||||
|
|
||||||
/// Settings screen for GitHub sync configuration.
|
/// Settings screen for GitHub sync configuration and note backup.
|
||||||
///
|
///
|
||||||
/// Primary path: the "Connect GitHub" button runs the OAuth **device flow**
|
/// Primary sync path: the "Connect GitHub" button runs the OAuth **device
|
||||||
/// (authorize in a browser, no token pasting). The manual token field
|
/// flow** (authorize in a browser, no token pasting). The manual token field
|
||||||
/// remains as a fallback.
|
/// remains as a fallback. The Backup section exports/imports all notes as a
|
||||||
|
/// single Markdown file (see [NotesMarkdown]).
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({required this.initial, super.key});
|
const SettingsScreen({
|
||||||
|
required this.initial,
|
||||||
|
required this.repository,
|
||||||
|
this.httpClient,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
final SyncSettings initial;
|
final SyncSettings initial;
|
||||||
|
final NoteRepository repository;
|
||||||
|
|
||||||
|
/// Optional HTTP client for the GitHub calls (test-connection and device
|
||||||
|
/// flow). Injected by tests; production uses each client's default.
|
||||||
|
final http.Client? httpClient;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
late final TextEditingController _owner =
|
late final TextEditingController _owner = TextEditingController(
|
||||||
TextEditingController(text: widget.initial.owner);
|
text: widget.initial.owner,
|
||||||
late final TextEditingController _repo =
|
);
|
||||||
TextEditingController(text: widget.initial.repo);
|
late final TextEditingController _repo = TextEditingController(
|
||||||
late final TextEditingController _token =
|
text: widget.initial.repo,
|
||||||
TextEditingController(text: widget.initial.token);
|
);
|
||||||
late final TextEditingController _clientId =
|
late final TextEditingController _token = TextEditingController(
|
||||||
TextEditingController(text: widget.initial.clientId);
|
text: widget.initial.token,
|
||||||
|
);
|
||||||
|
late final TextEditingController _clientId = TextEditingController(
|
||||||
|
text: widget.initial.clientId,
|
||||||
|
);
|
||||||
|
|
||||||
bool _testing = false;
|
bool _testing = false;
|
||||||
String? _status;
|
String? _status;
|
||||||
@ -43,11 +66,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SyncSettings get _current => SyncSettings(
|
SyncSettings get _current => SyncSettings(
|
||||||
owner: _owner.text.trim(),
|
owner: _owner.text.trim(),
|
||||||
repo: _repo.text.trim(),
|
repo: _repo.text.trim(),
|
||||||
token: _token.text.trim(),
|
token: _token.text.trim(),
|
||||||
clientId: _clientId.text.trim(),
|
clientId: _clientId.text.trim(),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Runs the OAuth device flow and, on success, fills in the token field.
|
/// Runs the OAuth device flow and, on success, fills in the token field.
|
||||||
Future<void> _connectGitHub() async {
|
Future<void> _connectGitHub() async {
|
||||||
@ -56,7 +79,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
setState(() => _status = 'Enter the OAuth App client id first.');
|
setState(() => _status = 'Enter the OAuth App client id first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final auth = GitHubDeviceAuth(clientId: clientId);
|
final auth = GitHubDeviceAuth(
|
||||||
|
clientId: clientId,
|
||||||
|
httpClient: widget.httpClient,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final device = await auth.requestDeviceCode();
|
final device = await auth.requestDeviceCode();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -85,12 +111,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_status = null;
|
_status = null;
|
||||||
});
|
});
|
||||||
final s = _current;
|
final s = _current;
|
||||||
final client = GitHubClient(owner: s.owner, repo: s.repo, token: s.token);
|
final client = GitHubClient(
|
||||||
|
owner: s.owner,
|
||||||
|
repo: s.repo,
|
||||||
|
token: s.token,
|
||||||
|
httpClient: widget.httpClient,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final ok = await client.canAccessRepo();
|
final ok = await client.canAccessRepo();
|
||||||
setState(() => _status = ok
|
setState(
|
||||||
? 'Connected — repo is reachable.'
|
() => _status = ok
|
||||||
: 'Could not access ${s.owner}/${s.repo}. Check token scope.');
|
? 'Connected — repo is reachable.'
|
||||||
|
: 'Could not access ${s.owner}/${s.repo}. Check token scope.',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _status = 'Error: $e');
|
setState(() => _status = 'Error: $e');
|
||||||
} finally {
|
} finally {
|
||||||
@ -105,6 +138,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (mounted) Navigator.of(context).pop(s);
|
if (mounted) Navigator.of(context).pop(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exports every note to a single Markdown file. On mobile this opens the
|
||||||
|
/// system share sheet; on desktop it writes the canonical `~/todo/
|
||||||
|
/// BACKLOG.md` so a future tool/agent has a stable path to read.
|
||||||
|
Future<void> _export() async {
|
||||||
|
try {
|
||||||
|
final notes = await widget.repository.listNotes();
|
||||||
|
final markdown = NotesMarkdown.export(notes);
|
||||||
|
|
||||||
|
// coverage:ignore-start
|
||||||
|
// Mobile-only share path: Platform.isAndroid/isIOS are always false on
|
||||||
|
// the Linux test host, so these lines are structurally unreachable in
|
||||||
|
// CI and excluded from the coverage denominator. Verified on-device.
|
||||||
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final file = File('${dir.path}/todo-backlog.md');
|
||||||
|
await file.writeAsString(markdown);
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
files: [XFile(file.path, mimeType: 'text/markdown')],
|
||||||
|
subject: 'todo backlog (${notes.length} notes)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// coverage:ignore-end
|
||||||
|
final home = Platform.environment['HOME'] ?? Directory.current.path;
|
||||||
|
final dir = Directory('$home/todo');
|
||||||
|
if (!dir.existsSync()) dir.createSync(recursive: true);
|
||||||
|
final file = File('${dir.path}/BACKLOG.md');
|
||||||
|
await file.writeAsString(markdown);
|
||||||
|
if (mounted) {
|
||||||
|
setState(
|
||||||
|
() => _status = 'Exported ${notes.length} notes to ${file.path}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _status = 'Export failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports notes from a user-picked Markdown file, merging by id so a
|
||||||
|
/// stale backup never clobbers a newer local edit (see
|
||||||
|
/// [NoteRepository.importNotes]).
|
||||||
|
Future<void> _import() async {
|
||||||
|
try {
|
||||||
|
const group = XTypeGroup(
|
||||||
|
label: 'Markdown',
|
||||||
|
extensions: ['md', 'markdown', 'txt'],
|
||||||
|
// UTIs/MIME so the picker accepts the file on iOS/Android too.
|
||||||
|
uniformTypeIdentifiers: ['net.daringfireball.markdown', 'public.text'],
|
||||||
|
mimeTypes: ['text/markdown', 'text/plain'],
|
||||||
|
);
|
||||||
|
final file = await openFile(acceptedTypeGroups: const [group]);
|
||||||
|
if (file == null) return; // user cancelled
|
||||||
|
final content = await file.readAsString();
|
||||||
|
|
||||||
|
final notes = NotesMarkdown.parse(content);
|
||||||
|
final outcome = await widget.repository.importNotes(notes);
|
||||||
|
if (mounted) {
|
||||||
|
setState(
|
||||||
|
() => _status =
|
||||||
|
'Imported ${outcome.total}: ${outcome.added} new, '
|
||||||
|
'${outcome.updated} updated, ${outcome.skipped} unchanged',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _status = 'Import failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -128,8 +231,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('Connect with GitHub',
|
Text(
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
'Connect with GitHub',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _clientId,
|
controller: _clientId,
|
||||||
@ -148,8 +253,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('Or paste a token (fallback)',
|
Text(
|
||||||
style: Theme.of(context).textTheme.titleMedium),
|
'Or paste a token (fallback)',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _token,
|
controller: _token,
|
||||||
@ -182,6 +289,32 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Backup', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Export all notes to a single Markdown file, or import/merge a '
|
||||||
|
'file back (matching ids are merged, never duplicated).',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _export,
|
||||||
|
icon: const Icon(Icons.upload_file),
|
||||||
|
label: const Text('Export notes'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _import,
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Import notes'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
if (_status != null) ...[
|
if (_status != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
|
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
|||||||
@ -6,9 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||||
|
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
116
pubspec.lock
116
pubspec.lock
@ -73,6 +73,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.1.3"
|
version: "5.1.3"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -113,6 +121,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
ffi_leak_tracker:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi_leak_tracker
|
||||||
|
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.2"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -121,6 +137,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: file_selector
|
||||||
|
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
file_selector_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_android
|
||||||
|
sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.2+8"
|
||||||
|
file_selector_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_ios
|
||||||
|
sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.3+5"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_web
|
||||||
|
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+5"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -264,6 +344,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
native_toolchain_c:
|
native_toolchain_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -353,7 +441,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.6"
|
version: "3.1.6"
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: plugin_platform_interface
|
name: plugin_platform_interface
|
||||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
@ -392,6 +480,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "0.6.0"
|
||||||
|
share_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: share_plus
|
||||||
|
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.1.0"
|
||||||
|
share_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: share_plus_platform_interface
|
||||||
|
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.1.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -614,7 +718,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.5"
|
version: "3.2.5"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: url_launcher_platform_interface
|
name: url_launcher_platform_interface
|
||||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||||
@ -669,6 +773,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
win32:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32
|
||||||
|
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -42,7 +42,8 @@ dependencies:
|
|||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
|
share_plus: ^13.1.0
|
||||||
|
file_selector: ^1.1.0
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@ -54,6 +55,12 @@ dev_dependencies:
|
|||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
# Plugin platform interfaces, depended on directly so tests can swap in
|
||||||
|
# fakes for the file picker and URL launcher (see settings_screen_test.dart).
|
||||||
|
file_selector_platform_interface: ^2.7.0
|
||||||
|
url_launcher_platform_interface: ^2.3.2
|
||||||
|
plugin_platform_interface: ^2.1.8
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
|||||||
138
test/capture_screen_test.dart
Normal file
138
test/capture_screen_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
104
test/fake_note_repository.dart
Normal file
104
test/fake_note_repository.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
98
test/github_client_test.dart
Normal file
98
test/github_client_test.dart
Normal 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>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -7,10 +7,10 @@ import 'package:todo/sync/github_device_auth.dart';
|
|||||||
|
|
||||||
/// Builds an auth instance whose polls resolve instantly (no real waiting).
|
/// Builds an auth instance whose polls resolve instantly (no real waiting).
|
||||||
GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth(
|
GitHubDeviceAuth authWith(http.Client client) => GitHubDeviceAuth(
|
||||||
clientId: 'test-client-id',
|
clientId: 'test-client-id',
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
delay: (_) => Future<void>.value(),
|
delay: (_) => Future<void>.value(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const _device = DeviceCodeResponse(
|
const _device = DeviceCodeResponse(
|
||||||
deviceCode: 'dev-123',
|
deviceCode: 'dev-123',
|
||||||
@ -50,10 +50,15 @@ void main() {
|
|||||||
calls++;
|
calls++;
|
||||||
// Pending on the first two polls, then success.
|
// Pending on the first two polls, then success.
|
||||||
if (calls < 3) {
|
if (calls < 3) {
|
||||||
return http.Response(jsonEncode({'error': 'authorization_pending'}), 200);
|
return http.Response(
|
||||||
|
jsonEncode({'error': 'authorization_pending'}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}), 200);
|
jsonEncode({'access_token': 'gho_abc', 'token_type': 'bearer'}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final token = await authWith(client).pollForToken(_device);
|
final token = await authWith(client).pollForToken(_device);
|
||||||
@ -67,7 +72,9 @@ void main() {
|
|||||||
calls++;
|
calls++;
|
||||||
if (calls == 1) {
|
if (calls == 1) {
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({'error': 'slow_down', 'interval': 1}), 200);
|
jsonEncode({'error': 'slow_down', 'interval': 1}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200);
|
return http.Response(jsonEncode({'access_token': 'gho_xyz'}), 200);
|
||||||
});
|
});
|
||||||
@ -77,13 +84,72 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('pollForToken throws on access_denied', () async {
|
test('pollForToken throws on access_denied', () async {
|
||||||
final client = MockClient((req) async => http.Response(
|
final client = MockClient(
|
||||||
jsonEncode({'error': 'access_denied', 'error_description': 'no'}), 200));
|
(req) async => http.Response(
|
||||||
|
jsonEncode({'error': 'access_denied', 'error_description': 'no'}),
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
() => authWith(client).pollForToken(_device),
|
() => authWith(client).pollForToken(_device),
|
||||||
throwsA(isA<DeviceAuthException>()
|
throwsA(
|
||||||
.having((e) => e.code, 'code', 'access_denied')),
|
isA<DeviceAuthException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
'access_denied',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pollForToken honors slow_down then succeeds', () async {
|
||||||
|
var calls = 0;
|
||||||
|
final client = MockClient((req) async {
|
||||||
|
calls++;
|
||||||
|
if (calls == 1) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'error': 'slow_down', 'interval': 0}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'access_token': 'gho_ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await authWith(client).pollForToken(_device), 'gho_ok');
|
||||||
|
expect(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pollForToken throws on an unexpected response shape', () async {
|
||||||
|
final client = MockClient(
|
||||||
|
(_) async => http.Response(jsonEncode({'foo': 'bar'}), 200),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => authWith(client).pollForToken(_device),
|
||||||
|
throwsA(isA<DeviceAuthException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pollForToken throws when the device code has expired', () async {
|
||||||
|
final client = MockClient(
|
||||||
|
(_) async => http.Response(jsonEncode({'access_token': 'x'}), 200),
|
||||||
|
);
|
||||||
|
const expired = DeviceCodeResponse(
|
||||||
|
deviceCode: 'd',
|
||||||
|
userCode: 'u',
|
||||||
|
verificationUri: 'v',
|
||||||
|
interval: 1,
|
||||||
|
expiresIn: 0, // deadline is now → loop body never runs
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => authWith(client).pollForToken(expired),
|
||||||
|
throwsA(
|
||||||
|
isA<DeviceAuthException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
'expired_token',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,30 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
import 'package:sqlite_crdt/sqlite_crdt.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(sqfliteFfiInit);
|
setUpAll(sqfliteFfiInit);
|
||||||
|
|
||||||
Note note(String id, String text, {Priority priority = Priority.none}) {
|
Note note(
|
||||||
|
String id,
|
||||||
|
String text, {
|
||||||
|
Priority priority = Priority.medium,
|
||||||
|
Status status = Status.todo,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return Note(
|
return Note(
|
||||||
id: id,
|
id: id,
|
||||||
text: text,
|
text: text,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
createdAt: now,
|
status: status,
|
||||||
updatedAt: now,
|
createdAt: createdAt ?? now,
|
||||||
|
updatedAt: updatedAt ?? now,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,4 +69,306 @@ void main() {
|
|||||||
expect(notes.first.text, 'high');
|
expect(notes.first.text, 'high');
|
||||||
expect(notes.last.text, 'low');
|
expect(notes.last.text, 'low');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('text search', () {
|
||||||
|
test('matches a case-insensitive substring', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await repo.upsert(note('a', 'Buy MILK and eggs'));
|
||||||
|
await repo.upsert(note('b', 'call the dentist'));
|
||||||
|
|
||||||
|
final notes = await repo.listNotes(
|
||||||
|
filter: const NoteFilter(query: 'milk'),
|
||||||
|
);
|
||||||
|
expect(notes.map((n) => n.id), ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes LIKE wildcards so % is matched literally', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await repo.upsert(note('pct', 'a%b'));
|
||||||
|
await repo.upsert(note('plain', 'axb'));
|
||||||
|
|
||||||
|
// Without escaping, 'a%b' as a LIKE pattern would also match 'axb'.
|
||||||
|
final notes = await repo.listNotes(
|
||||||
|
filter: const NoteFilter(query: 'a%b'),
|
||||||
|
);
|
||||||
|
expect(notes.map((n) => n.id), ['pct']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('attribute filters', () {
|
||||||
|
test('priority filter includes only the selected priorities', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await repo.upsert(note('lo', 'l', priority: Priority.low));
|
||||||
|
await repo.upsert(note('me', 'm', priority: Priority.medium));
|
||||||
|
await repo.upsert(note('hi', 'h', priority: Priority.high));
|
||||||
|
|
||||||
|
final notes = await repo.listNotes(
|
||||||
|
filter: const NoteFilter(priorities: {Priority.low, Priority.high}),
|
||||||
|
);
|
||||||
|
expect(notes.map((n) => n.id).toSet(), {'lo', 'hi'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status filter includes only the selected statuses', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await repo.upsert(note('t', 'todo', status: Status.todo));
|
||||||
|
await repo.upsert(note('d', 'done', status: Status.done));
|
||||||
|
await repo.upsert(note('x', 'gone', status: Status.abandoned));
|
||||||
|
|
||||||
|
final notes = await repo.listNotes(
|
||||||
|
filter: const NoteFilter(statuses: {Status.todo, Status.inProgress}),
|
||||||
|
);
|
||||||
|
expect(notes.map((n) => n.id), ['t']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('date range filters', () {
|
||||||
|
final jan = DateTime(2026, 1, 15, 10);
|
||||||
|
final jun = DateTime(2026, 6, 15, 10);
|
||||||
|
|
||||||
|
test('created range bounds are inclusive by calendar day', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await repo.upsert(note('j', 'jan', createdAt: jan, updatedAt: jan));
|
||||||
|
await repo.upsert(note('u', 'jun', createdAt: jun, updatedAt: jun));
|
||||||
|
|
||||||
|
// A single-day range on Jan 15 includes the 10:00 note that day.
|
||||||
|
final notes = await repo.listNotes(
|
||||||
|
filter: NoteFilter(
|
||||||
|
createdFrom: DateTime(2026, 1, 15),
|
||||||
|
createdTo: DateTime(2026, 1, 15),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(notes.map((n) => n.id), ['j']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('created and updated ranges apply independently', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
// Created in Jan, but last updated in Jun.
|
||||||
|
await repo.upsert(
|
||||||
|
note('e', 'edited later', createdAt: jan, updatedAt: jun),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Matches on the updated range...
|
||||||
|
final byUpdated = await repo.listNotes(
|
||||||
|
filter: NoteFilter(
|
||||||
|
updatedFrom: DateTime(2026, 6, 1),
|
||||||
|
updatedTo: DateTime(2026, 6, 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(byUpdated.map((n) => n.id), ['e']);
|
||||||
|
|
||||||
|
// ...but not when the created range excludes January.
|
||||||
|
final byCreated = await repo.listNotes(
|
||||||
|
filter: NoteFilter(
|
||||||
|
createdFrom: DateTime(2026, 6, 1),
|
||||||
|
createdTo: DateTime(2026, 6, 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(byCreated, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters combine with AND', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await repo.upsert(
|
||||||
|
note(
|
||||||
|
'match',
|
||||||
|
'urgent report',
|
||||||
|
priority: Priority.high,
|
||||||
|
status: Status.inProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repo.upsert(
|
||||||
|
note(
|
||||||
|
'wrongPrio',
|
||||||
|
'urgent report',
|
||||||
|
priority: Priority.low,
|
||||||
|
status: Status.inProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repo.upsert(
|
||||||
|
note(
|
||||||
|
'wrongText',
|
||||||
|
'casual note',
|
||||||
|
priority: Priority.high,
|
||||||
|
status: Status.inProgress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final notes = await repo.listNotes(
|
||||||
|
filter: const NoteFilter(
|
||||||
|
query: 'urgent',
|
||||||
|
priorities: {Priority.high},
|
||||||
|
statuses: {Status.inProgress},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(notes.map((n) => n.id), ['match']);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('priority defaults', () {
|
||||||
|
test('fromValue maps legacy/unknown values to medium', () {
|
||||||
|
expect(Priority.fromValue(0), Priority.medium); // old "none"
|
||||||
|
expect(Priority.fromValue(null), Priority.medium);
|
||||||
|
expect(Priority.fromValue(99), Priority.medium);
|
||||||
|
// Known values still round-trip.
|
||||||
|
expect(Priority.fromValue(1), Priority.low);
|
||||||
|
expect(Priority.fromValue(2), Priority.medium);
|
||||||
|
expect(Priority.fromValue(3), Priority.high);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('importNotes (safe merge)', () {
|
||||||
|
test('adds notes whose id is not present locally', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await repo.upsert(note('a', 'local'));
|
||||||
|
|
||||||
|
final outcome = await repo.importNotes([note('b', 'incoming')]);
|
||||||
|
|
||||||
|
expect(outcome.added, 1);
|
||||||
|
expect(outcome.updated, 0);
|
||||||
|
expect(outcome.skipped, 0);
|
||||||
|
expect((await repo.listNotes()).map((n) => n.id).toSet(), {'a', 'b'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overwrites a local note only when the import is newer', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
final old = DateTime(2026, 1, 1);
|
||||||
|
final newer = DateTime(2026, 6, 1);
|
||||||
|
await repo.upsert(note('a', 'local-old', updatedAt: old));
|
||||||
|
|
||||||
|
final outcome = await repo.importNotes([
|
||||||
|
note('a', 'imported-new', updatedAt: newer),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(outcome.updated, 1);
|
||||||
|
final stored = (await repo.listNotes()).single;
|
||||||
|
expect(stored.text, 'imported-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never clobbers a newer local edit with a stale import', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
final stale = DateTime(2026, 1, 1);
|
||||||
|
final fresh = DateTime(2026, 6, 1);
|
||||||
|
// Local note is the freshly-edited one.
|
||||||
|
await repo.upsert(note('a', 'local-fresh', updatedAt: fresh));
|
||||||
|
|
||||||
|
final outcome = await repo.importNotes([
|
||||||
|
note('a', 'backup-stale', updatedAt: stale),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(outcome.skipped, 1);
|
||||||
|
expect(outcome.updated, 0);
|
||||||
|
// The newer local edit survives — "never lose ideas".
|
||||||
|
expect((await repo.listNotes()).single.text, 'local-fresh');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('v2→v3 migration backfills priority 0 to medium', () async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp('todo_migration');
|
||||||
|
final path = '${dir.path}/notes.db';
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
|
||||||
|
// Build a v2 database (status column present, no priority backfill) and
|
||||||
|
// insert a legacy note with the old priority 0 ("none").
|
||||||
|
final v2 = await SqliteCrdt.open(
|
||||||
|
path,
|
||||||
|
version: 2,
|
||||||
|
onCreate: (db, version) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final now = DateTime.now().toIso8601String();
|
||||||
|
await v2.execute(
|
||||||
|
'INSERT INTO notes (id, text, priority, status, created_at, updated_at) '
|
||||||
|
'VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
|
||||||
|
['legacy', 'old idea', 0, 0, now, now],
|
||||||
|
);
|
||||||
|
await v2.close();
|
||||||
|
|
||||||
|
// Reopening through the repository runs onUpgrade to v3.
|
||||||
|
final repo = await NoteRepository.open(path);
|
||||||
|
addTearDown(repo.close);
|
||||||
|
final notes = await repo.listNotes();
|
||||||
|
|
||||||
|
expect(notes.single.id, 'legacy');
|
||||||
|
expect(notes.single.priority, Priority.medium);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('sorting and streams', () {
|
||||||
|
test('createdDesc and alphabetical orderings', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
final t1 = DateTime(2026, 1, 1);
|
||||||
|
final t2 = DateTime(2026, 2, 1);
|
||||||
|
await repo.upsert(note('a', 'banana', createdAt: t1, updatedAt: t1));
|
||||||
|
await repo.upsert(note('b', 'apple', createdAt: t2, updatedAt: t2));
|
||||||
|
|
||||||
|
final byCreated = await repo.listNotes(sort: NoteSort.createdDesc);
|
||||||
|
expect(byCreated.first.id, 'b'); // newest created first
|
||||||
|
|
||||||
|
final alpha = await repo.listNotes(sort: NoteSort.alphabetical);
|
||||||
|
expect(alpha.map((n) => n.text), ['apple', 'banana']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchNotes and watchCount emit current state', () async {
|
||||||
|
final repo = await NoteRepository.openInMemory();
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await repo.upsert(note('a', 'one'));
|
||||||
|
|
||||||
|
expect(await repo.watchNotes().first, hasLength(1));
|
||||||
|
expect(await repo.watchCount().first, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nodeId, changeset merge and close', () async {
|
||||||
|
// Use file-backed DBs: two openInMemory repos would share one `:memory:`
|
||||||
|
// connection, so they cannot model two independent devices.
|
||||||
|
final dir = await Directory.systemTemp.createTemp('todo_merge');
|
||||||
|
addTearDown(() => dir.delete(recursive: true));
|
||||||
|
final source = await NoteRepository.open('${dir.path}/source.db');
|
||||||
|
final target = await NoteRepository.open('${dir.path}/target.db');
|
||||||
|
addTearDown(target.close);
|
||||||
|
|
||||||
|
expect(source.nodeId, isNotEmpty);
|
||||||
|
await source.upsert(note('a', 'shared idea'));
|
||||||
|
final changeset = await source.getChangeset();
|
||||||
|
await source.close();
|
||||||
|
|
||||||
|
// getChangeset serialises hlc/modified as Strings and returns read-only
|
||||||
|
// rows; merge expects mutable maps with Hlc objects. Rebuild them the
|
||||||
|
// way the sync layer does after its JSON round-trip.
|
||||||
|
final revived = {
|
||||||
|
for (final entry in changeset.entries)
|
||||||
|
entry.key: [
|
||||||
|
for (final record in entry.value)
|
||||||
|
{
|
||||||
|
...record,
|
||||||
|
'hlc': Hlc.parse(record['hlc'] as String),
|
||||||
|
'modified': Hlc.parse(record['modified'] as String),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await target.merge(revived);
|
||||||
|
final merged = await target.listNotes();
|
||||||
|
expect(merged.single.text, 'shared idea');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
109
test/note_test.dart
Normal file
109
test/note_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
253
test/notes_list_screen_test.dart
Normal file
253
test/notes_list_screen_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
84
test/notes_markdown_test.dart
Normal file
84
test/notes_markdown_test.dart
Normal 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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
393
test/settings_screen_test.dart
Normal file
393
test/settings_screen_test.dart
Normal 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
152
test/sync_service_test.dart
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
69
test/sync_settings_test.dart
Normal file
69
test/sync_settings_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -25,11 +25,8 @@ Future<void> main() async {
|
|||||||
|
|
||||||
// Throwaway directory so we never pollute the real `changesets/`.
|
// Throwaway directory so we never pollute the real `changesets/`.
|
||||||
const service = SyncService(changesetDir: 'changesets_smoketest');
|
const service = SyncService(changesetDir: 'changesets_smoketest');
|
||||||
GitHubClient client() => GitHubClient(
|
GitHubClient client() =>
|
||||||
owner: 'kuhyx',
|
GitHubClient(owner: 'kuhyx', repo: 'todo-sync', token: token);
|
||||||
repo: 'todo-sync',
|
|
||||||
token: token,
|
|
||||||
);
|
|
||||||
|
|
||||||
final deviceA = await NoteRepository.openInMemory();
|
final deviceA = await NoteRepository.openInMemory();
|
||||||
final deviceB = await NoteRepository.openInMemory();
|
final deviceB = await NoteRepository.openInMemory();
|
||||||
@ -57,7 +54,8 @@ Future<void> main() async {
|
|||||||
'Idea from device A @ $stamp',
|
'Idea from device A @ $stamp',
|
||||||
'Idea from device B @ $stamp',
|
'Idea from device B @ $stamp',
|
||||||
};
|
};
|
||||||
final converged = aNotes.containsAll(expected) && bNotes.containsAll(expected);
|
final converged =
|
||||||
|
aNotes.containsAll(expected) && bNotes.containsAll(expected);
|
||||||
|
|
||||||
// Cleanup: remove the throwaway changeset files.
|
// Cleanup: remove the throwaway changeset files.
|
||||||
final cleanup = client();
|
final cleanup = client();
|
||||||
@ -73,7 +71,9 @@ Future<void> main() async {
|
|||||||
await deviceB.close();
|
await deviceB.close();
|
||||||
|
|
||||||
if (converged) {
|
if (converged) {
|
||||||
stdout.writeln('\n✅ PASS: both devices converged to both notes via GitHub.');
|
stdout.writeln(
|
||||||
|
'\n✅ PASS: both devices converged to both notes via GitHub.',
|
||||||
|
);
|
||||||
exit(0);
|
exit(0);
|
||||||
} else {
|
} else {
|
||||||
stdout.writeln('\n❌ FAIL: devices did not converge. Expected $expected.');
|
stdout.writeln('\n❌ FAIL: devices did not converge. Expected $expected.');
|
||||||
@ -83,11 +83,14 @@ Future<void> main() async {
|
|||||||
|
|
||||||
Future<void> _insert(NoteRepository repo, String text) async {
|
Future<void> _insert(NoteRepository repo, String text) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
await repo.upsert(Note(
|
await repo.upsert(
|
||||||
id: '${now.microsecondsSinceEpoch}-${text.hashCode}',
|
Note(
|
||||||
text: text,
|
id: '${now.microsecondsSinceEpoch}-${text.hashCode}',
|
||||||
priority: Priority.none,
|
text: text,
|
||||||
createdAt: now,
|
priority: Priority.medium,
|
||||||
updatedAt: now,
|
status: Status.todo,
|
||||||
));
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user