Compare commits

...

20 Commits

Author SHA1 Message Date
c9eb4d6cff Bump sqflite_common_ffi to 2.4.7
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 16:17:09 +01:00
1010d96426 deploy.sh: wait out phone_deploy load guard
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:57:08 +01:00
319f18d33a deploy.sh: one phone lease owner per run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:53:55 +01:00
c3ac6ec05d deploy.sh: SDK env and release key for phone
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Ship and systemd units skip ~/.zshenv, so default ANDROID_HOME, and
copy the shared key.properties into a fresh checkout so the release
build installs over CI builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:47:43 +01:00
0e56cddd47 Add scripts/deploy.sh and the deploy command
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Deploys the MCP service, the desktop pacman package and the phone
release build, each behind its own health check; exits non-zero unless
all pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:15:23 +01:00
203e61d6ac Text-free sync settings via ssui 0.7.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Bump sync_settings_ui 0.7.0 and github_device_auth 0.4.0 (glyph +
spoken label). Guard covers the Sync settings screen; degraded sync
gets its own warning glyph; tooltip delegate and copy button tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:32:02 +01:00
edf6969949 Template picker: glyph prefix, not float label
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:22:22 +01:00
5ea7ba7b73 Text-free list, editor, sync and image cues
Glyph + spoken label replaces every app-written string the guard
found: empty states, tile meta, sheet dates/fields, date presets,
template/section headings, status footer, snackbars, GitHub and
cloud-image results, upload/download badges, Material tooltips.
Guard tests (text_free_*_test) walk the painted tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:17:03 +01:00
091e68a2d9 Text-free UI: glyph chips, icon-only actions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:02:25 +01:00
a30e7b34a9 CLI: --set-text replaces a note's text
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Keeps every other field; text from --text or stdin, empty refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:56:23 +01:00
17d6d2a533 Bump file_picker, idb_shim, sqflite_common_ffi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:56:22 +01:00
4e8ccd6bfd Lean gradle.properties for warm daemon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 17:51:44 +01:00
6bd2923f0a Instant page routes; coalesce watch re-emits
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:57:03 +01:00
4c77dd5d2e Bulk update: diff generated fields only
planBulkUpdate compared fresh field values with the stored text, so a
peer's "...00Z" created_at (or a web build's millisecond date) read as a
change and got a fresh clock, erasing the created_by node signal; a
record without a priority field got one written. Compare the note's own
generated fields before and after instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:41:35 +01:00
757c54beda Multi-select notes with an icon action bar
An app-bar toggle enters selection mode: a tap toggles a note (tint +
check on grid tiles, ListTile.selected + check in the list), Ctrl/Cmd-
click toggles and Shift-click range-selects on desktop, long-press
outside the mode still opens the per-note sheet. While anything is
selected an icon-only bottom bar sets status or priority for the whole
selection in one bulkUpdate, assigns plain notes to a repo picked from
the repos on notes or typed as owner/name, or clears the selection. A
refused assign (note already in a repo) flashes a block icon. Labels
are semantic only; menus pass an empty tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:39:13 +01:00
5c1bf75bf9 Bulk note writes and assign-to-repo helper
NoteRepository.bulkUpdate sets status/priority on many notes in one
store write (mergeLogs + replaceAll; only changed fields plus updated_at
get a clock). saveReplacing saves one note and tombstones others in one
write. assignToRepo moves plain notes into the repo's open note as
"## title" sections via addRepoTask, demoting body headings below "##"
so a template note stays one section; notes already in a repo are
refused. The seeded watch stream moved to log_watch.dart for the
250-line cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:38:54 +01:00
57019eec30 Track who filed a note (created_by)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
New optional note field created_by (kuhy|claude), stored like by/repo and
omitted when unset so older builds stay wire-compatible. App UI stamps kuhy;
MCP add_task and the CLI stamp claude. list/get/search output includes it.
Human notes sort above Claude's; Claude notes are hidden behind a per-device
eye toggle. CLI --backfill-created-by [--dry-run] classifies old notes by the
node that wrote created_at, writing only the created_by field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:18:19 +01:00
cc9789f530 Start exactly the merged item
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The board orders cards by column, so a cancelled older item with the
same title could be listed last and was started instead of the item
the merge added (T1866). Start now targets <id8>-<sections - 1> only.

Todo: 64d39ab5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:28:17 +01:00
135f3f07ea Start a merged task outside the MCP lock
The board refreshes its notes through this MCP server, and add_task
held the server's serial lock while waiting for the board to show
the merged section, so the board could never see it (T1865). The
Start now runs after the lock is released.

Todo: 64d39ab5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:24:24 +01:00
ba25e8f176 Wait for the board before a merged Start
The board's note cache lags ~10 s after a merge, so Start found
"no startable item" (T1863); it now waits up to 30 s for the merged
section's card. A Start POST slower than its timeout that did take
effect was reported "board unreachable" (T1864); it now re-reads the
board and reports ok when the card left Start.

Todo: 64d39ab5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:18:30 +01:00
130 changed files with 4677 additions and 988 deletions

View File

@ -50,6 +50,7 @@ and sync peer-to-peer through a GitHub repo used as dumb storage.
- lint: `flutter analyze` - lint: `flutter analyze`
- coverage: `flutter test --coverage --reporter=compact` - coverage: `flutter test --coverage --reporter=compact`
- coverage-gaps: `coverage-gaps coverage/lcov.info` - coverage-gaps: `coverage-gaps coverage/lcov.info`
- deploy: `scripts/deploy.sh` (MCP service, desktop pacman package, phone release; exits non-zero unless every health check passes)
## Architecture ## Architecture

View File

@ -1,4 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+UseSerialGC -XX:TieredStopAtLevel=1
org.gradle.caching=true
org.gradle.parallel=true
kotlin.compiler.execution.strategy=in-process
android.useAndroidX=true android.useAndroidX=true
# This newDsl flag was added by the Flutter template # This newDsl flag was added by the Flutter template
android.newDsl=false android.newDsl=false

View File

@ -4,49 +4,12 @@
/// exact-pinned dependency under the freshness gate. /// exact-pinned dependency under the freshness gate.
library; library;
import 'package:todo/cli/cli_mode.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
export 'package:todo/cli/cli_mode.dart';
export 'package:todo/cli/cli_usage.dart'; export 'package:todo/cli/cli_usage.dart';
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Change one note's status and push it.
setStatus,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}
/// The parsed command line. /// The parsed command line.
class CliArgs { class CliArgs {
/// Creates parsed arguments; see [parse]. /// Creates parsed arguments; see [parse].
@ -58,6 +21,7 @@ class CliArgs {
this.status, this.status,
this.dataDir, this.dataDir,
this.newNote = false, this.newNote = false,
this.dryRun = false,
this.opts = ( this.opts = (
repo: null, repo: null,
query: null, query: null,
@ -89,12 +53,19 @@ class CliArgs {
opts[arg] = value(); opts[arg] = value();
case '--list': case '--list':
modes.add(CliMode.list); modes.add(CliMode.list);
case '--backfill-created-by':
modes.add(CliMode.backfill);
case '--dry-run':
opts[arg] = 'true';
case '--search': case '--search':
modes.add(CliMode.search); modes.add(CliMode.search);
opts[arg] = value(); opts[arg] = value();
case '--get' || '--delete' || '--close': case '--get' || '--delete' || '--close':
modes.add(CliMode.values.byName(arg.substring(2))); modes.add(CliMode.values.byName(arg.substring(2)));
id = value(); id = value();
case '--set-text':
modes.add(CliMode.setText);
id = value();
case '--set-status': case '--set-status':
modes.add(CliMode.setStatus); modes.add(CliMode.setStatus);
id = value(); id = value();
@ -106,8 +77,9 @@ class CliArgs {
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help); if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
if (modes.length > 1) { if (modes.length > 1) {
throw UsageError( throw UsageError(
'--list, --search, --get, --delete, --set-status and --close are ' '--list, --search, --get, --delete, --set-status, --set-text, '
'mutually exclusive', '--close and '
'--backfill-created-by are mutually exclusive',
); );
} }
final mode = modes.isEmpty ? CliMode.add : modes.single; final mode = modes.isEmpty ? CliMode.add : modes.single;
@ -128,6 +100,7 @@ class CliArgs {
opts.containsKey('--new-note') && opts.containsKey('--new-note') &&
(opts['--repo'] != null || (opts['--repo'] != null ||
(throw UsageError('--new-note needs --repo'))), (throw UsageError('--new-note needs --repo'))),
dryRun: opts.containsKey('--dry-run'),
opts: ( opts: (
repo: _repo(opts['--repo']), repo: _repo(opts['--repo']),
query: _nonBlank(opts['--search'], '--search'), query: _nonBlank(opts['--search'], '--search'),
@ -141,7 +114,8 @@ class CliArgs {
/// The selected mode. /// The selected mode.
final CliMode mode; final CliMode mode;
/// Note text from `--text`; null means "read stdin" in [CliMode.add]. /// Note text from `--text`; null means "read stdin" in [CliMode.add] and
/// [CliMode.setText].
final String? text; final String? text;
/// Full id or 8+ character prefix of the note an id mode acts on. /// Full id or 8+ character prefix of the note an id mode acts on.
@ -159,6 +133,9 @@ class CliArgs {
/// `--new-note`: the `--repo` task gets its own note. /// `--new-note`: the `--repo` task gets its own note.
final bool newNote; final bool newNote;
/// Whether `--dry-run` was given: report what would change, change nothing.
final bool dryRun;
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values. /// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
final ({ final ({
String? repo, String? repo,
@ -174,7 +151,7 @@ class CliArgs {
static const _adding = 'when adding a note'; static const _adding = 'when adding a note';
static const Map<String, (String, Set<CliMode>)> _scopes = { static const Map<String, (String, Set<CliMode>)> _scopes = {
'--text': (_adding, {CliMode.add}), '--text': ('$_adding or to --set-text', {CliMode.add, CliMode.setText}),
'--done-check': (_adding, {CliMode.add}), '--done-check': (_adding, {CliMode.add}),
'--by': (_adding, {CliMode.add}), '--by': (_adding, {CliMode.add}),
'--repo': ( '--repo': (
@ -183,6 +160,7 @@ class CliArgs {
), ),
'--evidence': ('to --close', {CliMode.close}), '--evidence': ('to --close', {CliMode.close}),
'--new-note': (_adding, {CliMode.add}), '--new-note': (_adding, {CliMode.add}),
'--dry-run': ('to --backfill-created-by', {CliMode.backfill}),
}; };
static void _checkScopes(Iterable<String> flags, CliMode mode) { static void _checkScopes(Iterable<String> flags, CliMode mode) {

View File

@ -38,6 +38,7 @@ class CliCommands {
repo: repo, repo: repo,
doneCheck: args.opts.doneCheck, doneCheck: args.opts.doneCheck,
by: args.opts.by, by: args.opts.by,
createdBy: 'claude',
); );
await session.repository.upsert(note); await session.repository.upsert(note);
if (!await _push('note')) return 1; if (!await _push('note')) return 1;
@ -57,6 +58,7 @@ class CliCommands {
doneCheck: args.opts.doneCheck, doneCheck: args.opts.doneCheck,
priority: args.priority, priority: args.priority,
by: args.opts.by, by: args.opts.by,
createdBy: 'claude',
); );
await _save(added.note); await _save(added.note);
if (!await _push('note')) return 1; if (!await _push('note')) return 1;
@ -79,6 +81,7 @@ class CliCommands {
doneCheck: args.opts.doneCheck, doneCheck: args.opts.doneCheck,
priority: args.priority, priority: args.priority,
by: args.opts.by, by: args.opts.by,
createdBy: 'claude',
); );
await _save(added.note); await _save(added.note);
if (!await _push('note')) return 1; if (!await _push('note')) return 1;
@ -133,6 +136,15 @@ class CliCommands {
return 0; return 0;
} }
/// Replaces a note's whole text (title is its first line) and pushes it.
Future<int> setText(String ref, String text) async {
final note = await _pullAndFind(ref);
await _save(note.copyWith(text: text));
if (!await _push('text change')) return 1;
_out.writeln('${note.id}\ttext');
return 0;
}
/// Marks a note done with [evidence], keeping it, and pushes it. /// Marks a note done with [evidence], keeping it, and pushes it.
Future<int> close(String ref, String evidence) async { Future<int> close(String ref, String evidence) async {
final note = await _pullAndFind(ref); final note = await _pullAndFind(ref);
@ -142,6 +154,35 @@ class CliCommands {
return 0; return 0;
} }
/// Sets `created_by` where unset (see [NoteRepository.backfillCreatedBy]).
/// Prints one `<id>\t<value>\t<title>` line per note, then the totals. With
/// [dryRun] nothing is written or pushed, and peers are only read.
Future<int> backfillCreatedBy(
Set<String> claudeNodes, {
required bool dryRun,
}) async {
_report(await session.sync(push: !dryRun));
final plan = await session.repository.backfillCreatedBy(
claudeNodes,
dryRun: dryRun,
);
final titles = {
for (final n in await session.repository.listNotes()) n.id: n,
};
for (final MapEntry(:key, :value) in plan.entries) {
final note = titles[key];
final title = note == null ? '' : note.text.split('\n').first;
_out.writeln(
'${key.substring(0, key.length.clamp(0, 8))}\t$value\t$title',
);
}
final claude = plan.values.where((v) => v == 'claude').length;
final kuhy = plan.length - claude;
_out.writeln('${dryRun ? 'dry run' : 'set'}: claude $claude, kuhy $kuhy');
if (dryRun || plan.isEmpty) return 0;
return await _push('backfill') ? 0 : 1;
}
Future<Note> _pullAndFind(String ref) async { Future<Note> _pullAndFind(String ref) async {
_report(await session.sync(push: true)); _report(await session.sync(push: true));
return findNote(await session.repository.listNotes(), ref); return findNote(await session.repository.listNotes(), ref);

47
lib/cli/cli_mode.dart Normal file
View File

@ -0,0 +1,47 @@
/// What the CLI can be asked to do, and how a bad command line is reported.
library;
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Change one note's status and push it.
setStatus,
/// Replace one note's whole text and push it.
setText,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Set `created_by` on notes that lack it.
backfill,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}

View File

@ -54,7 +54,7 @@ Future<int> runCli(
return 0; return 0;
} }
String? text; String? text;
if (args.mode == CliMode.add) { if (args.mode == CliMode.add || args.mode == CliMode.setText) {
text = args.text ?? await readStdin(); text = args.text ?? await readStdin();
if (text.trim().isEmpty) { if (text.trim().isEmpty) {
err.writeln('error: empty note (pass --text or pipe text on stdin)'); err.writeln('error: empty note (pass --text or pipe text on stdin)');
@ -79,7 +79,12 @@ Future<int> runCli(
CliMode.list || CliMode.search => await cli.list(args), CliMode.list || CliMode.search => await cli.list(args),
CliMode.get => await cli.get(args.id!), CliMode.get => await cli.get(args.id!),
CliMode.setStatus => await cli.setStatus(args.id!, args.status!), CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
CliMode.setText => await cli.setText(args.id!, text!),
CliMode.close => await cli.close(args.id!, args.opts.evidence!), CliMode.close => await cli.close(args.id!, args.opts.evidence!),
CliMode.backfill => await cli.backfillCreatedBy(
claudeNodeIds(env),
dryRun: args.dryRun,
),
// Only delete is left: help returned above. // Only delete is left: help returned above.
_ => await cli.delete(args.id!), _ => await cli.delete(args.id!),
}; };

View File

@ -29,6 +29,19 @@ String defaultDataDir(Map<String, String> env) {
return p.join(base, 'todo-cli'); return p.join(base, 'todo-cli');
} }
/// Node ids of the installs Claude files notes through: the MCP server and
/// the CLI under the same data root as [defaultDataDir]. Missing ones are
/// skipped.
Set<String> claudeNodeIds(Map<String, String> env) {
final cli = defaultDataDir(env);
return {
for (final dir in [cli, p.join(p.dirname(cli), 'todo-mcp')])
if (File(p.join(dir, CliSession.nodeIdFileName)) case final f
when f.existsSync())
f.readAsStringSync().trim(),
}..remove('');
}
/// The open state of one CLI invocation. Call [close] when done. /// The open state of one CLI invocation. Call [close] when done.
class CliSession { class CliSession {
/// Wraps already-open parts; [open] is the normal way in. Public so a test /// Wraps already-open parts; [open] is the normal way in. Public so a test

View File

@ -22,9 +22,14 @@ A note with --repo is a repo task; one without is a plain idea.
--get <id> pull, then print one note in full (exit 1 if absent) --get <id> pull, then print one note in full (exit 1 if absent)
--delete <id> pull, tombstone the note, push --delete <id> pull, tombstone the note, push
--set-status <id> <s> pull, change the note's status (as --status), push --set-status <id> <s> pull, change the note's status (as --status), push
--set-text <id> pull, replace the note's whole text with --text (or
stdin; first line = title), keep its other fields, push
--close <id> --evidence <s> --close <id> --evidence <s>
pull, set status done + evidence (commit sha + done pull, set status done + evidence (commit sha + done
check exit code), push; refused without --evidence check exit code), push; refused without --evidence
--backfill-created-by [--dry-run]
pull, set created_by (claude|kuhy) on every note that
has none, push; --dry-run prints the plan only
--data-dir <dir> local log + node id (default --data-dir <dir> local log + node id (default
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli) $XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
-h, --help this text -h, --help this text

View File

@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
return matches.single; return matches.single;
} }
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`. /// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>\t<created_by>`.
String listLine(Note note) => [ String listLine(Note note) => [
if (note.id.length <= _minPrefix) if (note.id.length <= _minPrefix)
note.id note.id
@ -42,6 +42,7 @@ String listLine(Note note) => [
note.priority.name, note.priority.name,
note.repo ?? '-', note.repo ?? '-',
_title(note), _title(note),
note.createdBy ?? 'kuhy',
].join('\t'); ].join('\t');
String _title(Note note) => note.text.split('\n').first; String _title(Note note) => note.text.split('\n').first;
@ -57,6 +58,7 @@ String noteDetail(Note note) => [
if (note.doneCheck case final v?) 'done_check: $v', if (note.doneCheck case final v?) 'done_check: $v',
if (note.by case final v?) 'by: $v', if (note.by case final v?) 'by: $v',
if (note.evidence case final v?) 'evidence: $v', if (note.evidence case final v?) 'evidence: $v',
'created_by: ${note.createdBy ?? 'kuhy'}',
'---', '---',
note.text, note.text,
].join('\n'); ].join('\n');

View File

@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart';
/// sibling to `changesets/<nodeId>.json`. /// sibling to `changesets/<nodeId>.json`.
const String kAdvancedModeRemotePath = 'settings/advancedMode'; const String kAdvancedModeRemotePath = 'settings/advancedMode';
/// App-wide, non-sync preferences (currently just `advancedMode`). /// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
class AppSettings { class AppSettings {
/// Creates an [AppSettings] from its stored fields. /// Creates an [AppSettings] from its stored fields.
const AppSettings({required this.advancedMode, this.advancedModeUpdatedAt}); const AppSettings({
required this.advancedMode,
this.advancedModeUpdatedAt,
this.showClaudeNotes = false,
});
/// Whether the priority/status/template/mode chrome and the routine /// Whether the priority/status/template/mode chrome and the routine
/// save/sync status text are shown. Off by default: casual capture needs /// save/sync status text are shown. Off by default: casual capture needs
@ -35,6 +39,11 @@ class AppSettings {
/// a value that has never round-tripped through Firebase. /// a value that has never round-tripped through Firebase.
final DateTime? advancedModeUpdatedAt; final DateTime? advancedModeUpdatedAt;
/// Whether the notes list shows notes Claude filed. Per device: it lives
/// only in this device's preferences and is never mirrored to Firebase.
final bool showClaudeNotes;
static const _kShowClaude = 'app.showClaudeNotes';
static const _kAdvancedMode = 'app.advancedMode'; static const _kAdvancedMode = 'app.advancedMode';
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt'; static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
@ -44,6 +53,7 @@ class AppSettings {
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt); final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
return AppSettings( return AppSettings(
advancedMode: prefs.getBool(_kAdvancedMode) ?? false, advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
showClaudeNotes: prefs.getBool(_kShowClaude) ?? false,
advancedModeUpdatedAt: updatedAtMillis == null advancedModeUpdatedAt: updatedAtMillis == null
? null ? null
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis), : DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
@ -71,6 +81,7 @@ class AppSettings {
final updated = AppSettings( final updated = AppSettings(
advancedMode: value, advancedMode: value,
advancedModeUpdatedAt: updatedAt, advancedModeUpdatedAt: updatedAt,
showClaudeNotes: showClaudeNotes,
); );
if (client != null) { if (client != null) {
try { try {
@ -89,6 +100,17 @@ class AppSettings {
return updated; return updated;
} }
/// Persists the per-device Claude-notes choice; nothing leaves the device.
Future<AppSettings> withShowClaudeNotes({required bool value}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowClaude, value);
return AppSettings(
advancedMode: advancedMode,
advancedModeUpdatedAt: advancedModeUpdatedAt,
showClaudeNotes: value,
);
}
/// Pulls the remote value from [client] and, if it is newer than the /// Pulls the remote value from [client] and, if it is newer than the
/// local one, applies it locally and returns the reconciled settings. /// local one, applies it locally and returns the reconciled settings.
/// Returns this instance unchanged when [client] is null, there is /// Returns this instance unchanged when [client] is null, there is
@ -100,9 +122,7 @@ class AppSettings {
try { try {
final remote = await client.getStringMap(kAdvancedModeRemotePath); final remote = await client.getStringMap(kAdvancedModeRemotePath);
if (!remote.containsKey('value')) return this; if (!remote.containsKey('value')) return this;
final remoteUpdatedMillis = int.tryParse( final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? '');
remote['updatedAtMillis'] ?? '',
);
if (remoteUpdatedMillis == null) return this; if (remoteUpdatedMillis == null) return this;
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch( final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
remoteUpdatedMillis, remoteUpdatedMillis,
@ -110,6 +130,7 @@ class AppSettings {
final candidate = AppSettings( final candidate = AppSettings(
advancedMode: remote['value'] == 'true', advancedMode: remote['value'] == 'true',
advancedModeUpdatedAt: remoteUpdatedAt, advancedModeUpdatedAt: remoteUpdatedAt,
showClaudeNotes: showClaudeNotes,
); );
final adopted = adopt(candidate); final adopted = adopt(candidate);
if (identical(adopted, this)) return this; if (identical(adopted, this)) return this;

View File

@ -0,0 +1,44 @@
/// Who filed a note, for notes written before `created_by` existed.
///
/// The `created_at` field's clock names the node that last wrote it. The MCP
/// and the CLI are the only nodes Claude writes through, so a note created
/// there carries one of their node ids; anything else came from kuhy's own
/// devices.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note_record.dart';
const _createdAt = 'created_at';
/// `claude` when [record]'s `created_at` was written by one of [claudeNodes],
/// else `kuhy`.
String classifyCreatedBy(Record record, Set<String> claudeNodes) {
final node = record.fields[_createdAt]?.$2.nodeId;
return node != null && claudeNodes.contains(node) ? 'claude' : 'kuhy';
}
/// Live records of [log] with no `created_by` yet, mapped to their value.
Map<String, String> planCreatedByBackfill(Log log, Set<String> claudeNodes) => {
for (final record in log.values)
if (!record.deleted &&
((record.fields[kCreatedByField]?.$1 as String?) ?? '').isEmpty)
record.id: classifyCreatedBy(record, claudeNodes),
};
/// [incoming], carrying [existing]'s `created_by` when [incoming] has none.
///
/// A store replaces a record wholesale, so an edit that does not know the
/// field (an import of an old backup) would otherwise drop it locally.
Record keepCreatedBy(Record incoming, Record? existing) {
final kept = existing?.fields[kCreatedByField];
if (kept == null || incoming.fields.containsKey(kCreatedByField)) {
return incoming;
}
return Record(
id: incoming.id,
fields: {...incoming.fields, kCreatedByField: kept},
deleted: incoming.deleted,
deletedHlc: incoming.deletedHlc,
);
}

38
lib/data/log_watch.dart Normal file
View File

@ -0,0 +1,38 @@
/// A seeded, coalesced stream over the note log's change events.
library;
import 'dart:async';
/// A stream that emits `compute()` when first listened to (the seed), then
/// again after [changes] fires. Seeding in `onListen` -- rather than lazily in
/// an `async*` body -- avoids a race where a write between `listen` and the
/// first generator tick would swallow the initial snapshot.
///
/// Re-emits are coalesced: the first change of a burst arms one
/// zero-duration timer and every further change before it fires is absorbed,
/// so N back-to-back changes cost ONE `compute()` carrying the latest state
/// instead of N full re-queries (each of which repaints the whole list).
/// The timer fires on the next event-loop turn, so tests stay deterministic
/// under `pumpEventQueue` and the widget tester's `pump`.
Stream<T> watchLog<T>(Stream<void> changes, T Function() compute) {
StreamSubscription<void>? sub;
Timer? pending;
late final StreamController<T> controller;
controller = StreamController<T>(
onListen: () {
controller.add(compute());
sub = changes.listen((_) {
pending ??= Timer(Duration.zero, () {
pending = null;
controller.add(compute());
});
});
},
onCancel: () {
pending?.cancel();
pending = null;
return sub?.cancel();
},
);
return controller.stream;
}

View File

@ -76,7 +76,7 @@ enum Status {
} }
} }
/// The values the optional `by` field may hold: who owns a repo task. /// The values the optional `by` and `created_by` fields may hold.
const Set<String> kNoteAuthors = {'claude', 'kuhy'}; const Set<String> kNoteAuthors = {'claude', 'kuhy'};
/// An immutable idea/note record. /// An immutable idea/note record.
@ -99,6 +99,7 @@ class Note {
this.doneCheck, this.doneCheck,
this.by, this.by,
this.evidence, this.evidence,
this.createdBy,
}); });
/// Builds a [Note] from a raw CRDT query row. /// Builds a [Note] from a raw CRDT query row.
@ -146,6 +147,12 @@ class Note {
/// Proof the task was closed (commit sha + the done check's exit code). /// Proof the task was closed (commit sha + the done check's exit code).
final String? evidence; final String? evidence;
/// Who filed the note: one of [kNoteAuthors]; null (older notes) means kuhy.
final String? createdBy;
/// Whether Claude filed this note (hidden by default, sorted last).
bool get isClaudeMade => createdBy == 'claude';
/// Whether this note is a repo task rather than a plain idea. /// Whether this note is a repo task rather than a plain idea.
bool get isRepoTask => repo != null; bool get isRepoTask => repo != null;
@ -162,6 +169,7 @@ class Note {
String? doneCheck, String? doneCheck,
String? by, String? by,
String? evidence, String? evidence,
String? createdBy,
}) { }) {
return Note( return Note(
id: id, id: id,
@ -174,6 +182,7 @@ class Note {
doneCheck: doneCheck ?? this.doneCheck, doneCheck: doneCheck ?? this.doneCheck,
by: by ?? this.by, by: by ?? this.by,
evidence: evidence ?? this.evidence, evidence: evidence ?? this.evidence,
createdBy: createdBy ?? this.createdBy,
); );
} }
} }

63
lib/data/note_bulk.dart Normal file
View File

@ -0,0 +1,63 @@
/// Partial logs for the multi-note writes: merged in one store write.
///
/// Each write re-serialises the whole log (over a megabyte on a real
/// device), so a bulk action must be one partial log merged once, never one
/// upsert per note. Merging is per field, so a field left out keeps its value
/// and its clock (`created_by` included).
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_record.dart';
/// Partial records setting [priority] and/or [status] on the live notes of
/// [log] among [ids], all at [hlc].
///
/// Only fields whose value changes are written, plus `updated_at` = [now]
/// (so "last updated" sorting and newer-wins import see the edit). A note
/// with nothing to change, an unknown id and a deleted note are left out.
Log planBulkUpdate(
Log log,
Set<String> ids, {
required Hlc hlc,
required DateTime now,
Priority? priority,
Status? status,
}) {
final plan = <String, Record>{};
for (final id in ids) {
final current = log[id];
if (current == null || current.deleted) continue;
final note = noteFromRecord(current);
final next = note.copyWith(priority: priority, status: status);
if (next.priority == note.priority && next.status == note.status) continue;
// Generated against generated, never against the stored text: a peer's
// `...00Z` (or a web build's millisecond date) would otherwise read as
// a change and get a fresh clock, erasing the `created_at` node signal.
final before = noteFields(note, hlc);
plan[id] = Record(
id: id,
fields: {
for (final MapEntry(:key, :value) in noteFields(
next.copyWith(updatedAt: now),
hlc,
).entries)
if (before[key]?.$1 != value.$1) key: value,
},
);
}
return plan;
}
/// [note] at [hlc] plus a tombstone for each live note of [log] in
/// [removed] (other than [note] itself).
///
/// A merge, not a replace: a field [note] leaves unset keeps its stored
/// value, so this suits notes that only gain content (the repo note an
/// assign appends to), not edits that clear a field.
Log planReplace(Log log, Note note, Iterable<String> removed, Hlc hlc) => {
for (final id in removed)
if (log[id] case final r? when !r.deleted && id != note.id)
id: Record(id: id, fields: const {}, deleted: true, deletedHlc: hlc),
note.id: Record(id: note.id, fields: noteFields(note, hlc)),
};

View File

@ -37,6 +37,7 @@ class NoteFilter {
this.statuses = const {}, this.statuses = const {},
this.repo, this.repo,
this.repoTasksOnly = false, this.repoTasksOnly = false,
this.hideClaude = false,
this.createdFrom, this.createdFrom,
this.createdTo, this.createdTo,
this.updatedFrom, this.updatedFrom,
@ -58,6 +59,10 @@ class NoteFilter {
/// Only notes that have a repo (repo tasks). Ideas are excluded. /// Only notes that have a repo (repo tasks). Ideas are excluded.
final bool repoTasksOnly; final bool repoTasksOnly;
/// Leave out notes Claude filed. A view preference rather than a user
/// facet, so it never counts toward [isEmpty] or [activeCount].
final bool hideClaude;
/// Inclusive lower bound (by calendar day) on the creation date. /// Inclusive lower bound (by calendar day) on the creation date.
final DateTime? createdFrom; final DateTime? createdFrom;
@ -104,6 +109,7 @@ class NoteFilter {
Set<Status>? statuses, Set<Status>? statuses,
String? repo, String? repo,
bool? repoTasksOnly, bool? repoTasksOnly,
bool? hideClaude,
DateTime? createdFrom, DateTime? createdFrom,
DateTime? createdTo, DateTime? createdTo,
DateTime? updatedFrom, DateTime? updatedFrom,
@ -117,6 +123,7 @@ class NoteFilter {
statuses: statuses ?? this.statuses, statuses: statuses ?? this.statuses,
repo: repo ?? this.repo, repo: repo ?? this.repo,
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly, repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
hideClaude: hideClaude ?? this.hideClaude,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom), createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo), createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom), updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
@ -138,6 +145,7 @@ bool matchesFilter(Note note, NoteFilter filter) {
} }
if (filter.repo != null && note.repo != filter.repo) return false; if (filter.repo != null && note.repo != filter.repo) return false;
if (filter.repoTasksOnly && note.repo == null) return false; if (filter.repoTasksOnly && note.repo == null) return false;
if (filter.hideClaude && note.isClaudeMade) return false;
if (filter.priorities.isNotEmpty && if (filter.priorities.isNotEmpty &&
!filter.priorities.contains(note.priority)) { !filter.priorities.contains(note.priority)) {
return false; return false;
@ -165,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
return true; return true;
} }
/// The comparator implementing [sort]. /// The comparator implementing [sort]: human-made notes first, Claude-made
/// ones after, and [sort] orders each group.
Comparator<Note> comparatorFor(NoteSort sort) { Comparator<Note> comparatorFor(NoteSort sort) {
final within = _within(sort);
return (a, b) {
if (a.isClaudeMade != b.isClaudeMade) return a.isClaudeMade ? 1 : -1;
return within(a, b);
};
}
Comparator<Note> _within(NoteSort sort) {
switch (sort) { switch (sort) {
case NoteSort.createdDesc: case NoteSort.createdDesc:
return (a, b) => b.createdAt.compareTo(a.createdAt); return (a, b) => b.createdAt.compareTo(a.createdAt);

View File

@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
const _by = 'by'; const _by = 'by';
const _evidence = 'evidence'; const _evidence = 'evidence';
/// Record field naming who filed the note (`kuhy` or `claude`).
const kCreatedByField = 'created_by';
/// Record fields for [note], all at [hlc]. Unset task fields are omitted, so /// Record fields for [note], all at [hlc]. Unset task fields are omitted, so
/// an idea's record is exactly the v3 field set (and cannot clear a peer's). /// an idea's record is exactly the v3 field set (and cannot clear a peer's).
Map<String, Field> noteFields(Note note, Hlc hlc) => { Map<String, Field> noteFields(Note note, Hlc hlc) => {
@ -26,6 +29,7 @@ Map<String, Field> noteFields(Note note, Hlc hlc) => {
if (note.doneCheck case final v?) _doneCheck: (v, hlc), if (note.doneCheck case final v?) _doneCheck: (v, hlc),
if (note.by case final v?) _by: (v, hlc), if (note.by case final v?) _by: (v, hlc),
if (note.evidence case final v?) _evidence: (v, hlc), if (note.evidence case final v?) _evidence: (v, hlc),
if (note.createdBy case final v?) kCreatedByField: (v, hlc),
}; };
/// Reads [record] as a [Note], ignoring unknown fields. /// Reads [record] as a [Note], ignoring unknown fields.
@ -48,6 +52,7 @@ Note noteFromRecord(Record record) {
doneCheck: task(_doneCheck), doneCheck: task(_doneCheck),
by: task(_by), by: task(_by),
evidence: task(_evidence), evidence: task(_evidence),
createdBy: task(kCreatedByField),
); );
} }

View File

@ -1,7 +1,8 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart'; import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/created_by_backfill.dart';
import 'package:todo/data/log_watch.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_bulk.dart';
import 'package:todo/data/note_import.dart'; import 'package:todo/data/note_import.dart';
import 'package:todo/data/note_query.dart'; import 'package:todo/data/note_query.dart';
import 'package:todo/data/note_record.dart'; import 'package:todo/data/note_record.dart';
@ -114,9 +115,57 @@ class NoteRepository {
/// character-by-character autosave: it is cheap and idempotent. All fields /// character-by-character autosave: it is cheap and idempotent. All fields
/// share one fresh monotonic clock (they are written together). /// share one fresh monotonic clock (they are written together).
Future<void> upsert(Note note) async { Future<void> upsert(Note note) async {
await _store.upsert( await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc())));
Record(id: note.id, fields: noteFields(note, _store.nextHlc())), }
);
Future<void> _put(Record record) =>
_store.upsert(keepCreatedBy(record, _store.get(record.id)));
/// Sets `created_by` on every live note that has none, from the node that
/// wrote its `created_at` (see [classifyCreatedBy]); returns id -> value.
///
/// Writes only that field, at a fresh clock, so no other field's clock
/// moves (a rewrite of `created_at` would erase the signal itself). With
/// [dryRun] nothing is written.
Future<Map<String, String>> backfillCreatedBy(
Set<String> claudeNodes, {
bool dryRun = false,
}) async {
final plan = planCreatedByBackfill(_store.snapshot(), claudeNodes);
if (dryRun || plan.isEmpty) return plan;
final hlc = _store.nextHlc();
await _patch({
for (final e in plan.entries)
e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}),
});
return plan;
}
/// Sets [priority] and/or [status] on the live notes [ids] in ONE store
/// write (see [planBulkUpdate]); a no-op when nothing changes.
Future<void> bulkUpdate(
Set<String> ids, {
Priority? priority,
Status? status,
}) => _patch(
planBulkUpdate(
_store.snapshot(),
ids,
hlc: _store.nextHlc(),
now: DateTime.now(),
priority: priority,
status: status,
),
);
/// Saves [note] and deletes [removed] in ONE store write ([planReplace]).
Future<void> saveReplacing(Note note, Iterable<String> removed) =>
_patch(planReplace(_store.snapshot(), note, removed, _store.nextHlc()));
/// Merges a partial log in one write: untouched fields keep their clocks.
Future<void> _patch(Log partial) async {
if (partial.isEmpty) return;
await _store.replaceAll(mergeLogs(_store.snapshot(), partial));
} }
/// Soft-deletes a note. The log keeps a sticky tombstone so the deletion /// Soft-deletes a note. The log keeps a sticky tombstone so the deletion
@ -142,10 +191,10 @@ class NoteRepository {
// the import wins locally; the clock decides whether it wins on *other* // the import wins locally; the clock decides whether it wins on *other*
// devices, and an import must not win there simply by being recent. // devices, and an import must not win there simply by being recent.
if (local == null) { if (local == null) {
await _store.upsert(recordAtNoteTime(note, _nodeId)); await _put(recordAtNoteTime(note, _nodeId));
added++; added++;
} else if (note.updatedAt.isAfter(local.updatedAt)) { } else if (note.updatedAt.isAfter(local.updatedAt)) {
await _store.upsert(recordAtNoteTime(note, _nodeId)); await _put(recordAtNoteTime(note, _nodeId));
updated++; updated++;
} else { } else {
skipped++; skipped++;
@ -165,27 +214,10 @@ class NoteRepository {
Stream<List<Note>> watchNotes({ Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.priorityDesc, NoteSort sort = NoteSort.priorityDesc,
NoteFilter filter = const NoteFilter(), NoteFilter filter = const NoteFilter(),
}) => _watch(() => _query(sort, filter)); }) => watchLog(_store.changes, () => _query(sort, filter));
/// Emits the live count of non-deleted notes immediately and on each change. /// Emits the live count of non-deleted notes immediately and on each change.
Stream<int> watchCount() => _watch(_liveCount); Stream<int> watchCount() => watchLog(_store.changes, _liveCount);
/// A stream that emits `compute()` synchronously when first listened to (the
/// seed), then again on every log change. Seeding in `onListen` — rather than
/// lazily in an `async*` body — avoids a race where a write between `listen`
/// and the first generator tick would swallow the initial snapshot.
Stream<T> _watch<T>(T Function() compute) {
StreamSubscription<void>? sub;
late final StreamController<T> controller;
controller = StreamController<T>(
onListen: () {
controller.add(compute());
sub = _store.changes.listen((_) => controller.add(compute()));
},
onCancel: () => sub?.cancel(),
);
return controller.stream;
}
/// This device's stable node id. Names its file in the sync repo so two /// This device's stable node id. Names its file in the sync repo so two
/// devices never write the same path. /// devices never write the same path.

75
lib/data/repo_assign.dart Normal file
View File

@ -0,0 +1,75 @@
/// Assigning plain notes to a repo from the UI: each becomes a `## title`
/// section of the repo's one open note ([addRepoTask]), and the original goes.
///
/// Only the data layer: nothing here talks to the board or starts a task.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
final _heading = RegExp(r'^(#+)(\s.*)$');
/// Whether [value] looks like `owner/name`.
bool isRepoName(String value) => _repoShape.hasMatch(value.trim());
/// [body] with every heading outside code fences pushed below `##`.
///
/// A repo note splits into tasks at `##` headings, so a template note's own
/// `## what` / `## must` would otherwise turn into tasks of their own.
String demoteHeadings(String body) {
var fenced = false;
final out = <String>[];
for (final line in body.split('\n')) {
if (line.trimLeft().startsWith('```')) fenced = !fenced;
final m = fenced ? null : _heading.firstMatch(line);
if (m == null) {
out.add(line);
continue;
}
final level = m.group(1)!.length;
out.add('${'#' * (level < 2 ? 3 : level + 1)}${m.group(2)}');
}
return out.join('\n');
}
/// Moves the plain [notes] into [repo]'s open note in one write, creating
/// that note when the repo has none; returns the notes refused because
/// they already belong to a repo (left untouched).
Future<List<Note>> assignToRepo(
NoteRepository repository,
List<Note> notes,
String repo,
) async {
final refused = [
for (final n in notes)
if (n.repo != null) n,
];
final plain = [
for (final n in notes)
if (n.repo == null) n,
];
if (plain.isEmpty) return refused;
final first = splitNote(plain.first.text);
var target = (await addRepoTask(
repository,
repo: repo,
title: first.title,
body: demoteHeadings(first.body),
priority: plain.first.priority,
createdBy: 'kuhy',
)).note;
for (final n in plain.skip(1)) {
final (:title, :body) = splitNote(n.text);
target = target.copyWith(
text: '${target.text}\n\n${repoSection(title, demoteHeadings(body))}',
priority: maxPriority(target.priority, n.priority),
);
}
await repository.saveReplacing(
target.copyWith(updatedAt: DateTime.now()),
plain.map((n) => n.id),
);
return refused;
}

View File

@ -71,6 +71,7 @@ Future<RepoAdd> addRepoTask(
String? doneCheck, String? doneCheck,
Priority priority = Priority.medium, Priority priority = Priority.medium,
String? by, String? by,
String? createdBy,
}) async { }) async {
final section = repoSection(title, body); final section = repoSection(title, body);
final open = (await repository.listNotes( final open = (await repository.listNotes(
@ -99,6 +100,7 @@ Future<RepoAdd> addRepoTask(
repo: repo, repo: repo,
doneCheck: composeDoneCheck(null, doneCheck), doneCheck: composeDoneCheck(null, doneCheck),
by: by, by: by,
createdBy: createdBy,
), ),
appended: false, appended: false,
); );

View File

@ -26,16 +26,24 @@ typedef SplitAdd = ({Note note, SplitKind kind, String? why});
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$'); final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
/// The open `## title` headings of [text], outside code fences. /// Every `## title` heading of [text] outside code fences, open or done.
Iterable<String> openSections(String text) sync* { Iterable<RegExpMatch> _sections(String text) sync* {
var fenced = false; var fenced = false;
for (final line in text.split('\n')) { for (final line in text.split('\n')) {
if (line.trimLeft().startsWith('```')) fenced = !fenced; if (line.trimLeft().startsWith('```')) fenced = !fenced;
final m = fenced ? null : _heading.firstMatch(line); final m = fenced ? null : _heading.firstMatch(line);
if (m != null && m.group(1) == null) yield m.group(2)!.trim(); if (m != null) yield m;
} }
} }
/// The open `## title` headings of [text], outside code fences.
Iterable<String> openSections(String text) => _sections(
text,
).where((m) => m.group(1) == null).map((m) => m.group(2)!.trim());
/// How many board items [text] splits into: one per section, done or not.
int sectionCount(String text) => _sections(text).length;
/// The separate open note of [repo] with an open section titled [title]. /// The separate open note of [repo] with an open section titled [title].
Note? duplicateTarget(Iterable<Note> open, String repo, String title) { Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
final hits = final hits =
@ -69,6 +77,7 @@ Future<SplitAdd> addSeparateTask(
String? doneCheck, String? doneCheck,
Priority priority = Priority.medium, Priority priority = Priority.medium,
String? by, String? by,
String? createdBy,
Future<bool> Function(Note note)? busy, Future<bool> Function(Note note)? busy,
}) async { }) async {
final section = repoSection(title, body); final section = repoSection(title, body);
@ -105,6 +114,7 @@ Future<SplitAdd> addSeparateTask(
repo: repo, repo: repo,
doneCheck: composeDoneCheck(null, doneCheck), doneCheck: composeDoneCheck(null, doneCheck),
by: by, by: by,
createdBy: createdBy,
), ),
kind: why == null ? SplitKind.added : SplitKind.fellBack, kind: why == null ? SplitKind.added : SplitKind.fellBack,
why: why, why: why,

View File

@ -11,6 +11,7 @@ import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repository_factory.dart'; import 'package:todo/data/repository_factory.dart';
import 'package:todo/frame_stats.dart'; import 'package:todo/frame_stats.dart';
import 'package:todo/ui/capture_screen.dart'; import 'package:todo/ui/capture_screen.dart';
import 'package:todo/ui/text_free_localizations.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -69,6 +70,7 @@ class TodoApp extends StatelessWidget {
store: imageStore, store: imageStore,
child: MaterialApp( child: MaterialApp(
title: 'todo', title: 'todo',
localizationsDelegates: kTextFreeLocalizations,
theme: buildLightTheme(), theme: buildLightTheme(),
darkTheme: buildDarkTheme(), darkTheme: buildDarkTheme(),
home: CaptureScreen( home: CaptureScreen(

View File

@ -5,6 +5,7 @@
/// a board that is down must not lose the task that was just saved. /// a board that is down must not lose the task that was just saved.
library; library;
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -19,17 +20,34 @@ final _csrf = RegExp(r'<meta\s+name="todoimpl-csrf"\s+content="([^"]+)"');
/// Talks to the board over [client] at [base]. /// Talks to the board over [client] at [base].
class BoardClient { class BoardClient {
/// [client] is injectable so tests need no network. /// [client] is injectable so tests need no network. [wait] bounds how
BoardClient({http.Client? client, Uri? base}) /// long Start waits for the board to see a merged section (its note cache
: _http = client ?? http.Client(), /// lags ~10 s, T1863) and to confirm a Start whose reply outran
base = base ?? kBoardBase; /// [startTimeout] (T1864); [poll] is the gap between board reads.
BoardClient({
http.Client? client,
Uri? base,
this.wait = const Duration(seconds: 30),
this.poll = const Duration(seconds: 1),
this.startTimeout = const Duration(seconds: 30),
}) : _http = client ?? http.Client(),
base = base ?? kBoardBase;
final http.Client _http; final http.Client _http;
/// The board's origin; also the `Origin` header of a POST. /// The board's origin; also the `Origin` header of a POST.
final Uri base; final Uri base;
static const _timeout = Duration(seconds: 5); /// Longest wait for the board to catch up with the note.
final Duration wait;
/// Gap between two board reads while waiting.
final Duration poll;
/// Longest wait for the reply to a Start POST.
final Duration startTimeout;
static const _timeout = Duration(seconds: 10);
Future<List<Map<String, dynamic>>> _cards(String noteId) async { Future<List<Map<String, dynamic>>> _cards(String noteId) async {
final res = await _http.get(base.resolve('/api/board')).timeout(_timeout); final res = await _http.get(base.resolve('/api/board')).timeout(_timeout);
@ -54,30 +72,79 @@ class BoardClient {
} }
} }
/// Clicks Start on the note's last startable item titled [title]. /// Clicks Start on the note's last startable item titled [title]. With
Future<StartResult> start(String noteId, String title) async { /// [sections], that is the note's last section (`<id8>-<sections - 1>`),
/// which a merge just added: waits until the board shows it.
Future<StartResult> start(
String noteId,
String title, {
int? sections,
}) async {
try { try {
final card = (await _cards(noteId)).reversed final last = sections == null ? null : _item(noteId, sections - 1);
.where((c) => c['title'] == title && _startable(c)) bool seen(List<Map<String, dynamic>> cs) =>
.firstOrNull; last == null || cs.any((c) => c['id'] == last);
final cards = await _until(noteId, seen);
// Exactly the merged item: never an older same-titled one, which the
// board may list last (it orders by column, T1866).
final card = seen(cards)
? cards.reversed
.where((c) => last == null || c['id'] == last)
.where((c) => c['title'] == title && _startable(c))
.firstOrNull
: null;
if (card == null) return _no('no startable item'); if (card == null) return _no('no startable item');
final page = await _http.get(base).timeout(_timeout); final page = await _http.get(base).timeout(_timeout);
final token = _csrf.firstMatch(page.body)?.group(1); final token = _csrf.firstMatch(page.body)?.group(1);
if (token == null) return _no('no CSRF token on the board page'); if (token == null) return _no('no CSRF token on the board page');
final id = card['id']! as String; final id = card['id']! as String;
final res = await _http return await _post(noteId, id, token);
.post(
base.resolve('/api/task/$id/start'),
headers: {'Origin': base.origin, 'X-Todoimpl-Token': token},
)
.timeout(_timeout);
if (res.statusCode == 200) return (ok: true, reason: null, item: id);
return _no('board refused start: ${res.statusCode} ${_message(res)}');
} on Object catch (e) { } on Object catch (e) {
return _no('board unreachable: $e'); return _no('board unreachable: $e');
} }
} }
Future<StartResult> _post(String noteId, String id, String token) async {
final http.Response res;
try {
res = await _http
.post(
base.resolve('/api/task/$id/start'),
headers: {'Origin': base.origin, 'X-Todoimpl-Token': token},
)
.timeout(startTimeout);
} on TimeoutException catch (e) {
// The board may still be starting it: a card that left Start took it.
final cards = await _until(
noteId,
(cs) => cs.any((c) => c['id'] == id && !_startable(c)),
);
if (cards.any((c) => c['id'] == id && !_startable(c))) {
return (ok: true, reason: null, item: id);
}
return _no('board unreachable: $e');
}
if (res.statusCode == 200) return (ok: true, reason: null, item: id);
return _no('board refused start: ${res.statusCode} ${_message(res)}');
}
/// The note's cards once [done] holds, or the last read after [wait].
Future<List<Map<String, dynamic>>> _until(
String noteId,
bool Function(List<Map<String, dynamic>>) done,
) async {
final deadline = DateTime.now().add(wait);
var cards = await _cards(noteId);
while (!done(cards) && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(poll);
cards = await _cards(noteId);
}
return cards;
}
static String _item(String noteId, int index) =>
'${noteId.substring(0, 8)}-$index';
static bool _startable(Map<String, dynamic> card) => (card['actions'] as List) static bool _startable(Map<String, dynamic> card) => (card['actions'] as List)
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.any((a) => a['id'] == 'start' && a['enabled'] == true); .any((a) => a['id'] == 'start' && a['enabled'] == true);

View File

@ -17,7 +17,7 @@ class TaskService {
final BoardClient? _board; final BoardClient? _board;
Future<void> _tail = Future.value(); Future<void> _tail = Future.value();
Future<String> _serial(Future<String> Function() body) { Future<T> _serial<T>(Future<T> Function() body) {
final run = _tail.then((_) => body()); final run = _tail.then((_) => body());
_tail = run.then((_) {}, onError: (_) {}); _tail = run.then((_) {}, onError: (_) {});
return run; return run;
@ -44,8 +44,14 @@ class TaskService {
} }
} }
/// `add_task`: joins the repo's open note (by: claude), or creates it. /// `add_task`: joins the repo's open note (by: claude), or creates it. A
Future<String> add(Map<String, dynamic> args) => _serial(() async { /// board Start after a merge runs once the other calls may go again.
Future<String> add(Map<String, dynamic> args) async {
final (:reply, :start) = await _serial(() => _add(args));
return start == null ? reply : '$reply\n${await start()}';
}
Future<SplitReply> _add(Map<String, dynamic> args) async {
final a = _Args(args); final a = _Args(args);
final repo = a.repo(required: true)!; final repo = a.repo(required: true)!;
final title = a.required('title'); final title = a.required('title');
@ -70,9 +76,11 @@ class TaskService {
doneCheck: a.optional('done_check'), doneCheck: a.optional('done_check'),
priority: a.priority() ?? Priority.medium, priority: a.priority() ?? Priority.medium,
by: 'claude', by: 'claude',
createdBy: 'claude',
); );
return await _save(added.note, added.appended ? 'appended to' : 'added'); final verb = added.appended ? 'appended to' : 'added';
}); return (reply: await _save(added.note, verb), start: null);
}
/// `list_tasks`: repo tasks, open ones unless `status` says otherwise. /// `list_tasks`: repo tasks, open ones unless `status` says otherwise.
Future<String> list(Map<String, dynamic> args) => _serial(() async { Future<String> list(Map<String, dynamic> args) => _serial(() async {

View File

@ -10,9 +10,14 @@ import 'package:todo/mcp/board_client.dart';
/// Saves [note] and returns `<verb> <id>` plus its list line. /// Saves [note] and returns `<verb> <id>` plus its list line.
typedef SaveNote = Future<String> Function(Note note, String verb); typedef SaveNote = Future<String> Function(Note note, String verb);
/// Adds a separate task, merging into a duplicate's note when it is idle, /// A saved split add: its reply so far, and the board Start still to run.
/// and clicks Start on the board after a merge that may run. /// The caller runs [start] after releasing its lock, since the board learns
Future<String> addSplit( /// about the merged section by calling this same server (T1865).
typedef SplitReply = ({String reply, Future<String> Function()? start});
/// Adds a separate task, merging into a duplicate's note when it is idle;
/// a merge that may run comes back with the Start to click on the board.
Future<SplitReply> addSplit(
NoteRepository repository, NoteRepository repository,
BoardClient? board, BoardClient? board,
SaveNote save, { SaveNote save, {
@ -30,19 +35,33 @@ Future<String> addSplit(
doneCheck: doneCheck, doneCheck: doneCheck,
priority: priority, priority: priority,
by: 'claude', by: 'claude',
createdBy: 'claude',
busy: board == null ? null : (n) => board.running(n.id), busy: board == null ? null : (n) => board.running(n.id),
); );
if (added.kind != SplitKind.merged) { if (added.kind != SplitKind.merged) {
final reply = await save(added.note, 'added'); final reply = await save(added.note, 'added');
return added.why == null ? reply : '$reply\n${added.why}'; return (
reply: added.why == null ? reply : '$reply\n${added.why}',
start: null,
);
} }
final reply = await save(added.note, 'merged into'); final reply = await save(added.note, 'merged into');
final block = startBlock(doneCheck, body); final block = startBlock(doneCheck, body);
if (block != null) return '$reply\nstart: skipped ($block)'; if (block != null) {
final start = board == null return (reply: '$reply\nstart: skipped ($block)', start: null);
? (ok: false, reason: 'no board configured', item: null) }
: await board.start(added.note.id, title.trim()); Future<String> start() async {
if (start.ok) return '$reply\nstart: ok ${start.item}'; final s = board == null
return '$reply\nstart: {ok: false, reason: "${start.reason}"}; ' ? (ok: false, reason: 'no board configured', item: null)
'the merge is saved, click Start on the board by hand'; : await board.start(
added.note.id,
title.trim(),
sections: sectionCount(added.note.text),
);
if (s.ok) return 'start: ok ${s.item}';
return 'start: {ok: false, reason: "${s.reason}"}; '
'the merge is saved, click Start on the board by hand';
}
return (reply: reply, start: start);
} }

View File

@ -116,7 +116,8 @@ McpServer _server(TaskService tasks) {
tool( tool(
'list_tasks', 'list_tasks',
'List repo tasks, one line each: 8-char id, status, priority, repo, ' 'List repo tasks, one line each: 8-char id, status, priority, repo, '
'title. Default status "open" (todo + in_progress).', 'title, created_by (kuhy|claude). Default status "open" '
'(todo + in_progress).',
{ {
'repo': repo, 'repo': repo,
'status': JsonSchema.string( 'status': JsonSchema.string(

View File

@ -92,6 +92,7 @@ class NotesMarkdown {
doneCheck: attrs['done_check'], doneCheck: attrs['done_check'],
by: attrs['by'], by: attrs['by'],
evidence: attrs['evidence'], evidence: attrs['evidence'],
createdBy: attrs['created_by'],
), ),
); );
} }
@ -113,6 +114,7 @@ class NotesMarkdown {
('done_check', note.doneCheck), ('done_check', note.doneCheck),
('by', note.by), ('by', note.by),
('evidence', note.evidence), ('evidence', note.evidence),
('created_by', note.createdBy),
]) ])
if (value != null) ' $name="${_escape(value)}"', if (value != null) ' $name="${_escape(value)}"',
].join(); ].join();

View File

@ -1,28 +1,26 @@
/// The one page route every screen is pushed with, on the shared motion tokens. /// The one page route every screen is pushed with.
library; library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:todo/ui/motion.dart';
/// A route for [builder]: fade plus a short rise over [AppDuration.slow], /// A route for [builder] that switches screens instantly: no fade, no slide.
/// instant under the OS "remove animations" setting. ///
Route<T> appRoute<T>(BuildContext context, WidgetBuilder builder) => /// Without a GPU the desktop web build repaints the whole 4K canvas in
_AppPageRoute<T>( /// software on every frame, so a 320 ms transition cost ~600 ms of main
builder: builder, /// thread and a 260-300 ms frame stall on every tab switch and note open.
duration: motionDuration(context, AppDuration.slow), /// The same instant switch applies on the phone. The context is unused but
); /// kept so every call site stays as it was.
Route<T> appRoute<T>(BuildContext _, WidgetBuilder builder) =>
_AppPageRoute<T>(builder: builder);
class _AppPageRoute<T> extends MaterialPageRoute<T> { class _AppPageRoute<T> extends MaterialPageRoute<T> {
_AppPageRoute({required super.builder, required this.duration}); _AppPageRoute({required super.builder});
final Duration duration;
@override @override
Duration get transitionDuration => duration; Duration get transitionDuration => Duration.zero;
@override @override
Duration get reverseTransitionDuration => duration; Duration get reverseTransitionDuration => Duration.zero;
@override @override
Widget buildTransitions( Widget buildTransitions(
@ -30,21 +28,5 @@ class _AppPageRoute<T> extends MaterialPageRoute<T> {
Animation<double> animation, Animation<double> animation,
Animation<double> secondaryAnimation, Animation<double> secondaryAnimation,
Widget child, Widget child,
) { ) => child;
final enter = CurvedAnimation(
parent: animation,
curve: AppCurve.decelerate,
reverseCurve: AppCurve.accelerate,
);
return FadeTransition(
opacity: enter,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.04),
end: Offset.zero,
).animate(enter),
child: child,
),
);
}
} }

View File

@ -44,18 +44,24 @@ Future<List<XFile>> pickAttachments(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.photo_camera_outlined), leading: const Icon(
title: const Text('Camera'), Icons.photo_camera_outlined,
semanticLabel: 'Camera',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.camera), onTap: () => Navigator.pop(context, AttachmentPickSource.camera),
), ),
ListTile( ListTile(
leading: const Icon(Icons.photo_library_outlined), leading: const Icon(
title: const Text('Gallery'), Icons.photo_library_outlined,
semanticLabel: 'Gallery',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.gallery), onTap: () => Navigator.pop(context, AttachmentPickSource.gallery),
), ),
ListTile( ListTile(
leading: const Icon(Icons.insert_drive_file_outlined), leading: const Icon(
title: const Text('File'), Icons.insert_drive_file_outlined,
semanticLabel: 'File',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.file), onTap: () => Navigator.pop(context, AttachmentPickSource.file),
), ),
], ],
@ -102,8 +108,7 @@ class AttachButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( return IconButton(
tooltip: 'Attach', icon: const Icon(Icons.attach_file, semanticLabel: 'Attach'),
icon: const Icon(Icons.attach_file),
onPressed: () async { onPressed: () async {
final files = await (picking ?? pickAttachments)(context); final files = await (picking ?? pickAttachments)(context);
if (files.isNotEmpty) onFiles(files); if (files.isNotEmpty) onFiles(files);

View File

@ -86,13 +86,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
// Not gated on advanced mode: attaching is basic capture. // Not gated on advanced mode: attaching is basic capture.
AttachButton(onFiles: onAttachFiles, picking: attachmentPicking), AttachButton(onFiles: onAttachFiles, picking: attachmentPicking),
IconButton( IconButton(
tooltip: 'New note',
onPressed: onNewNote, onPressed: onNewNote,
icon: const Icon(Icons.check), icon: const Icon(Icons.check, semanticLabel: 'New note'),
), ),
if (advanced) if (advanced)
IconButton( IconButton(
tooltip: 'Sync',
onPressed: syncing ? null : onSync, onPressed: syncing ? null : onSync,
icon: syncing icon: syncing
? const SizedBox( ? const SizedBox(
@ -100,12 +98,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.sync), : const Icon(Icons.sync, semanticLabel: 'Sync'),
), ),
IconButton( IconButton(
tooltip: 'Notes',
onPressed: onOpenList, onPressed: onOpenList,
icon: const Icon(Icons.list), icon: const Icon(Icons.list, semanticLabel: 'Notes'),
), ),
// Live count of stored notes, proving local persistence. Sits directly // Live count of stored notes, proving local persistence. Sits directly
// right of the Notes button it belongs to, and carries no icon of its // right of the Notes button it belongs to, and carries no icon of its
@ -122,9 +119,8 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
), ),
CaptureConfirmMark(captured: captured), CaptureConfirmMark(captured: captured),
IconButton( IconButton(
tooltip: 'Settings',
onPressed: onOpenSettings, onPressed: onOpenSettings,
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings, semanticLabel: 'Settings'),
), ),
], ],
); );

View File

@ -102,6 +102,7 @@ class CaptureDraft {
status: status, status: status,
createdAt: _createdAt!, createdAt: _createdAt!,
updatedAt: now, updatedAt: now,
createdBy: 'kuhy',
), ),
); );
if (live()) lastSavedAt.value = now; if (live()) lastSavedAt.value = now;

View File

@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart'; import 'package:crdt_sync/crdt_sync.dart';
import 'package:design_system/design_system.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:http/http.dart' as http;
@ -22,6 +21,7 @@ import 'package:todo/ui/capture_draft.dart';
import 'package:todo/ui/capture_status.dart'; import 'package:todo/ui/capture_status.dart';
import 'package:todo/ui/capture_status_footer.dart'; import 'package:todo/ui/capture_status_footer.dart';
import 'package:todo/ui/capture_sync_runner.dart'; import 'package:todo/ui/capture_sync_runner.dart';
import 'package:todo/ui/cue_snack.dart';
import 'package:todo/ui/image_attach.dart'; import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/image_input.dart'; import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/note_form.dart'; import 'package:todo/ui/note_form.dart';
@ -194,7 +194,14 @@ class _CaptureScreenState extends State<CaptureScreen>
Future<void> _sync() async { Future<void> _sync() async {
final settings = _settings ?? await SyncSettings.load(); final settings = _settings ?? await SyncSettings.load();
if (!settings.isConfigured && await openFirebase() == null) { if (!settings.isConfigured && await openFirebase() == null) {
_showSnack('Connect Firebase (or add a GitHub token) in settings'); // The cue plus the settings screen opening is the whole message.
if (mounted) {
showCue(
context,
Icons.cloud_off,
'Connect Firebase (or add a GitHub token) in settings',
);
}
await _openSettings(); await _openSettings();
return; return;
} }
@ -215,12 +222,7 @@ class _CaptureScreenState extends State<CaptureScreen>
if (outcome.ok) 'detail': outcome.detail, if (outcome.ok) 'detail': outcome.detail,
}, },
); );
_showSnack( _showSyncCue(outcome);
outcome.ok
? 'Synced: ${outcome.detail}'
: 'Sync failed: ${outcome.detail}',
isError: !outcome.ok,
);
} finally { } finally {
if (mounted) setState(() => _syncing = false); if (mounted) setState(() => _syncing = false);
} }

View File

@ -104,13 +104,20 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
/// replace-don't-queue behaviour match every other app; [isError] picks /// replace-don't-queue behaviour match every other app; [isError] picks
/// the danger hue and the longer duration, because an error the user /// the danger hue and the longer duration, because an error the user
/// missed is an error they will hit again. /// missed is an error they will hit again.
void _showSnack(String message, {bool isError = false}) { /// A manual sync's result as a glyph: done, done-but-degraded (Firebase
/// off or a peer unreadable, in the danger hue), or failed. The summary
/// sentence is spoken only; the status line keeps the failure time.
void _showSyncCue(CaptureSyncOutcome outcome) {
if (!mounted) return; if (!mounted) return;
if (isError) { final spoken = outcome.ok
showError(context, message); ? 'Synced: ${outcome.detail}'
} else { : 'Sync failed: ${outcome.detail}';
showToast(context, message); final (icon, error) = !outcome.ok
} ? (Icons.sync_problem, true)
: outcome.degraded
? (Icons.warning_amber, true)
: (Icons.cloud_done, false);
showCue(context, icon, spoken, error: error);
} }
/// Reloads the persisted last-sync outcome so the status line survives a /// Reloads the persisted last-sync outcome so the status line survives a

View File

@ -44,16 +44,16 @@ class CaptureStatusFooter extends StatelessWidget {
if (status == null || status.ok) { if (status == null || status.ok) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Glyph + time in the danger hue; the error itself is spoken.
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text( child: _cueLine(
Icons.sync_problem,
formatSyncTime(status.time),
'Sync failed at ${formatSyncTime(status.time)} · ' 'Sync failed at ${formatSyncTime(status.time)} · '
'${status.detail}', '${status.detail}',
style: theme.textTheme.bodySmall?.copyWith( theme.textTheme.bodySmall,
color: theme.colorScheme.error, theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
); );
}, },
@ -63,15 +63,45 @@ class CaptureStatusFooter extends StatelessWidget {
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: ValueListenableBuilder<DateTime?>( child: ValueListenableBuilder<DateTime?>(
valueListenable: lastSavedAt, valueListenable: lastSavedAt,
builder: (context, savedAt, _) => Text( // Before the first save: the save glyph alone, muted.
savedAt == null builder: (context, savedAt, _) => savedAt == null
? 'Autosaves as you type' ? _cueLine(
: 'Saved locally at ${formatSyncTime(savedAt)}', Icons.save_outlined,
style: theme.textTheme.bodySmall, '',
), 'Autosaves as you type',
theme.textTheme.bodySmall,
theme.colorScheme.outline,
)
: _cueLine(
Icons.save,
formatSyncTime(savedAt),
'Saved locally at ${formatSyncTime(savedAt)}',
theme.textTheme.bodySmall,
null,
),
), ),
), ),
], ],
); );
} }
/// One status line: a small glyph and a time, spoken as [spoken].
Widget _cueLine(
IconData icon,
String time,
String spoken,
TextStyle? style,
Color? color,
) => Semantics(
label: spoken,
excludeSemantics: true,
container: true,
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 4),
Text(time, style: style?.copyWith(color: color)),
],
),
);
} }

View File

@ -26,11 +26,16 @@ class CaptureSyncOutcome {
required this.ok, required this.ok,
required this.detail, required this.detail,
this.reconciled, this.reconciled,
this.degraded = false,
}); });
/// Whether the pass completed without throwing. /// Whether the pass completed without throwing.
final bool ok; final bool ok;
/// A pass that completed but not fully: Firebase was not connected, or a
/// peer's file could not be read. Shown as a warning, not a plain success.
final bool degraded;
/// One-line summary: merged/skipped counts on success, the error otherwise. /// One-line summary: merged/skipped counts on success, the error otherwise.
final String detail; final String detail;
@ -90,10 +95,12 @@ class CaptureSyncRunner {
); );
final detail = describeSyncResult(result.syncResult); final detail = describeSyncResult(result.syncResult);
await status.record(ok: true, detail: detail, live: live()); await status.record(ok: true, detail: detail, live: live());
final synced = result.syncResult;
return CaptureSyncOutcome( return CaptureSyncOutcome(
ok: true, ok: true,
detail: detail, detail: detail,
reconciled: result.appSettings, reconciled: result.appSettings,
degraded: !synced.firebaseConnected || synced.skippedFiles.isNotEmpty,
); );
} on Exception catch (e) { } on Exception catch (e) {
await status.record(ok: false, detail: '$e', live: live()); await status.record(ok: false, detail: '$e', live: live());

View File

@ -11,6 +11,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:todo/attachments/image_password_store.dart'; import 'package:todo/attachments/image_password_store.dart';
@ -18,6 +19,7 @@ import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart'; import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/dufs_image_client.dart'; import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/ui/app_route.dart'; import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/status_cue.dart';
/// The command that creates the login, shown wherever it is missing. /// The command that creates the login, shown wherever it is missing.
const kAddLoginCommand = const kAddLoginCommand =
@ -34,8 +36,7 @@ class CloudImagesTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) => ListTile( Widget build(BuildContext context) => ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Cloud images'), leading: const Icon(Icons.cloud, semanticLabel: 'Cloud images'),
subtitle: const Text('Where attached images and files upload (dufs)'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>( onTap: () => Navigator.of(context).push<void>(
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)), appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
@ -69,7 +70,7 @@ class CloudImagesScreen extends StatefulWidget {
class _CloudImagesScreenState extends State<CloudImagesScreen> { class _CloudImagesScreenState extends State<CloudImagesScreen> {
final _password = TextEditingController(); final _password = TextEditingController();
ImageStoreStatus? _status; ImageStoreStatus? _status;
String? _checkResult; StatusCue? _checkResult;
bool _checking = false; bool _checking = false;
@override @override
@ -98,7 +99,11 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
final password = _password.text.trim(); final password = _password.text.trim();
setState(() => _checking = true); setState(() => _checking = true);
final saved = await widget.passwords.write(password); final saved = await widget.passwords.write(password);
var result = 'Could not save: the keystore is unavailable'; var result = const StatusCue(
Icons.no_encryption,
'Could not save: the keystore is unavailable',
error: true,
);
if (saved) { if (saved) {
final client = DufsImageClient( final client = DufsImageClient(
DufsLogin(user: kDufsImagesUser, password: password), DufsLogin(user: kDufsImagesUser, password: password),
@ -106,9 +111,17 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
); );
final check = await client.check(); final check = await client.check();
result = switch (check) { result = switch (check) {
DufsCheck.ok => 'Saved — dufs accepted the login', DufsCheck.ok => const StatusCue.ok('Saved — dufs accepted the login'),
DufsCheck.rejected => 'Saved, but dufs rejected the password', DufsCheck.rejected => const StatusCue(
DufsCheck.unreachable => 'Saved — dufs is unreachable right now', Icons.lock,
'Saved, but dufs rejected the password',
error: true,
),
DufsCheck.unreachable => const StatusCue(
Icons.cloud_off,
'Saved — dufs is unreachable right now',
error: true,
),
}; };
// Upload what queued up while there was no password, now rather than // Upload what queued up while there was no password, now rather than
// on the next sync. No note texts: an empty ref set only uploads, it // on the next sync. No note texts: an empty ref set only uploads, it
@ -129,7 +142,9 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final status = _status; final status = _status;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Cloud images')), appBar: AppBar(
title: const Icon(Icons.cloud_outlined, semanticLabel: 'Cloud images'),
),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@ -138,36 +153,78 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
controller: _password, controller: _password,
obscureText: true, obscureText: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Password for the "todo" dufs login', prefixIcon: Icon(
Icons.lock_outline,
semanticLabel: 'Password for the "todo" dufs login',
),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton( FilledButton(
onPressed: _checking ? null : _saveAndTest, onPressed: _checking ? null : _saveAndTest,
child: const Text('Save & test'), child: const Icon(Icons.save, semanticLabel: 'Save & test'),
), ),
if (_checkResult case final result?) ...[ if (_checkResult case final result?) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text(result), StatusCueView(result),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
if (status != null) ...[ if (status != null) ...[
Text( // Shape + colour: a filled check, or a red error ring followed by
status.configured // a copy button that puts the fixing command on the clipboard.
? 'Login: configured' Row(
: 'Login: missing — create it on the PC with', children: [
Icon(
status.configured ? Icons.check_circle : Icons.error_outline,
color: status.configured
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.error,
semanticLabel: status.configured
? 'Login: configured'
: 'Login: missing — create it on the PC with',
),
if (!status.configured)
IconButton(
onPressed: () => unawaited(
Clipboard.setData(
const ClipboardData(text: kAddLoginCommand),
),
),
icon: const Icon(
Icons.content_copy,
semanticLabel: 'Copy: $kAddLoginCommand',
),
),
],
), ),
if (!status.configured) const SelectableText(kAddLoginCommand),
const SizedBox(height: 8), const SizedBox(height: 8),
Text('Waiting to upload: ${status.pending}'), // Icon + the count itself: the number is the information.
Row(
children: [
const Icon(Icons.cloud_upload_outlined),
const SizedBox(width: 8),
Text(
'${status.pending}',
semanticsLabel: 'Waiting to upload: ${status.pending}',
),
],
),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
Text( // Shrunk + no location, as glyphs; the sentence is spoken only.
'Images are shrunk to 2560px JPEGs without location data before ' const Row(
'they leave this device. Other files upload unchanged, at any ' children: [
'size.', Icon(
style: Theme.of(context).textTheme.bodySmall, Icons.photo_size_select_large,
semanticLabel:
'Images are shrunk to 2560px JPEGs without location data '
'before they leave this device. Other files upload '
'unchanged, at any size.',
),
SizedBox(width: 8),
Icon(Icons.location_off),
],
), ),
], ],
), ),

View File

@ -0,0 +1,58 @@
/// Icon-only confirmation before deleting a note.
///
/// Replaces design_system's `confirmDestructive` here because that dialog
/// paints its title, message and button labels as text. The safety contract
/// is kept: Cancel is autofocused (a reflexive Return keeps the note), Escape
/// and the barrier answer "no", and the confirm glyph wears the error colour.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Asks whether to delete a note; true only when the delete glyph is pressed.
Future<bool> confirmDeleteNote(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => const _ConfirmDeleteDialog(),
);
return confirmed ?? false;
}
class _ConfirmDeleteDialog extends StatelessWidget {
const _ConfirmDeleteDialog();
@override
Widget build(BuildContext context) {
final error = Theme.of(context).colorScheme.error;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () =>
Navigator.of(context).pop(false),
},
child: AlertDialog(
// Spoken when the dialog opens; the old written body.
semanticLabel: 'This cannot be undone.',
title: Icon(
Icons.delete_forever,
size: 40,
color: error,
semanticLabel: 'Delete note?',
),
actionsAlignment: MainAxisAlignment.spaceEvenly,
actions: [
IconButton(
key: const ValueKey('confirm-cancel'),
autofocus: true,
onPressed: () => Navigator.of(context).pop(false),
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
),
IconButton(
key: const ValueKey('confirm-delete'),
onPressed: () => Navigator.of(context).pop(true),
icon: Icon(Icons.delete, color: error, semanticLabel: 'Delete'),
),
],
),
);
}
}

58
lib/ui/cue_snack.dart Normal file
View File

@ -0,0 +1,58 @@
/// Transient feedback as a glyph, not a sentence (kuhy's textgate rule).
///
/// The design system's `showToast`/`showError` paint a message; this paints
/// an icon (plus an optional number) and keeps the old sentence as the
/// spoken label only. Colour is the second channel: errors use the danger
/// hue and stay up longer, as `showError` does.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
const Duration _toastDuration = Duration(seconds: 3);
const Duration _errorDuration = Duration(seconds: 5);
/// Shows [icon] (and [count], if given) in a snack bar; [spoken] is what a
/// screen reader says. Returns quietly when no `ScaffoldMessenger` is above
/// [context] (an async callback outliving its screen).
void showCue(
BuildContext context,
IconData icon,
String spoken, {
bool error = false,
int? count,
}) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
final colors = context.statusColors;
final fg = error ? colors.onStatus : null;
// Replace rather than queue: a queued cue describes an action the user
// has already moved on from.
messenger
..clearSnackBars()
..showSnackBar(
SnackBar(
content: Semantics(
label: spoken,
excludeSemantics: true,
// Its own node: otherwise the label merges into the snack bar's.
container: true,
child: Row(
children: [
Icon(icon, color: fg),
if (count != null) ...[
const SizedBox(width: AppSpacing.sm),
Text('$count', style: TextStyle(color: fg)),
],
],
),
),
backgroundColor: error ? colors.danger : null,
duration: error ? _errorDuration : _toastDuration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
);
}

View File

@ -0,0 +1,23 @@
/// The notes list's empty state as a picture, not a sentence.
library;
import 'package:flutter/material.dart';
/// A large muted glyph: an empty inbox when there are no notes at all, a
/// crossed-out filter when the current filter hides every note (the filter
/// button's badge is the second channel saying a filter is on).
class EmptyNotesCue extends StatelessWidget {
/// Creates the cue; [filtered] picks the filter variant.
const EmptyNotesCue({required this.filtered, super.key});
/// Whether a filter is active (notes may exist but none match).
final bool filtered;
@override
Widget build(BuildContext context) => Icon(
filtered ? Icons.filter_alt_off_outlined : Icons.inbox_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline,
semanticLabel: filtered ? 'No notes match these filters' : 'No notes yet',
);
}

105
lib/ui/enum_glyphs.dart Normal file
View File

@ -0,0 +1,105 @@
/// Glyphs for the Priority and Status enums, and the chip row that picks
/// them, so every picker shows the same shape + colour for the same value.
///
/// The enum's `label` survives only as the spoken (semantics) label.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/note_grid_tile.dart';
/// Shape channel for a priority: up for high, level for medium, down for low.
/// Colour ([priorityColor]) is the second channel.
IconData priorityIcon(Priority p) => switch (p) {
Priority.high => Icons.keyboard_double_arrow_up,
Priority.medium => Icons.drag_handle,
Priority.low => Icons.keyboard_double_arrow_down,
};
/// Heading glyph standing for "status" (matches the bulk-status button).
const IconData kStatusHeadingIcon = Icons.task_alt;
/// Heading glyph standing for "priority" (matches the bulk-priority button).
const IconData kPriorityHeadingIcon = Icons.flag_outlined;
/// [s] as its glyph, spoken as its label.
Widget statusGlyph(Status s, {double? size}) =>
Icon(statusIcon(s), size: size, semanticLabel: s.label);
/// [p] as its tinted glyph, spoken as its label.
Widget priorityGlyph(BuildContext context, Priority p, {double? size}) => Icon(
priorityIcon(p),
size: size,
color: priorityColor(p, Theme.of(context).colorScheme),
semanticLabel: p.label,
);
/// A heading glyph followed by one chip per value, each chip a glyph.
///
/// [multi] switches between FilterChips (toggle many) and ChoiceChips (pick
/// one); either way the selected chips are filled and checked.
class GlyphChips<T> extends StatelessWidget {
/// Creates a chip row for [values].
const GlyphChips({
required this.heading,
required this.semanticLabel,
required this.values,
required this.selected,
required this.glyphOf,
required this.onTap,
this.multi = false,
super.key,
});
/// Glyph shown in front of the chips in place of a written heading.
final IconData heading;
/// Spoken name of the row (the old written heading).
final String semanticLabel;
/// Every value offered.
final List<T> values;
/// Values currently selected.
final Set<T> selected;
/// Builds the glyph for one value.
final Widget Function(T) glyphOf;
/// Called with the tapped value.
final ValueChanged<T> onTap;
/// Whether this is a multi-select (filter) row.
final bool multi;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(heading, semanticLabel: semanticLabel),
const SizedBox(width: 12),
Expanded(
child: Wrap(
spacing: 8,
children: [
for (final v in values)
if (multi)
FilterChip(
label: glyphOf(v),
selected: selected.contains(v),
onSelected: (_) => onTap(v),
)
else
ChoiceChip(
label: glyphOf(v),
selected: selected.contains(v),
onSelected: (_) => onTap(v),
),
],
),
),
],
);
}
}

View File

@ -9,6 +9,7 @@ import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note_repository.dart'; import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/run_sync.dart'; import 'package:todo/sync/run_sync.dart';
import 'package:todo/sync/sync_settings.dart'; import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/status_cue.dart';
part 'github_mirror_screen_widget.dart'; part 'github_mirror_screen_widget.dart';
@ -27,7 +28,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
); );
bool _testing = false; bool _testing = false;
String? _status; StatusCue? _status;
@override @override
void dispose() { void dispose() {
@ -49,8 +50,13 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
Future<void> _connectGitHub() async { Future<void> _connectGitHub() async {
final clientId = _clientId.text.trim(); final clientId = _clientId.text.trim();
if (clientId.isEmpty) { if (clientId.isEmpty) {
setState(() => _status = 'Enter the OAuth App client id first.'); return _show(
return; const StatusCue(
Icons.key_off,
'Enter the OAuth App client id first.',
error: true,
),
);
} }
final auth = GitHubDeviceAuth( final auth = GitHubDeviceAuth(
clientId: clientId, clientId: clientId,
@ -67,20 +73,19 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
setState(() { setState(() {
_token.text = token; _token.text = token;
_status = 'Connected — syncing…'; _status = const StatusCue(Icons.sync, 'Connected — syncing…');
}); });
await _current.save(); await _current.save();
await _syncAfterConnect(); await _syncAfterConnect();
} }
} on Exception catch (e) { } on Exception catch (e) {
if (mounted) setState(() => _status = 'Could not start device flow: $e'); _show(StatusCue.failed('Could not start device flow: $e'));
} finally { } finally {
auth.close(); auth.close();
} }
} }
/// Runs a sync right after connecting so the user's notes download /// Syncs right after connecting so notes download at once, with a cue.
/// immediately and they get clear confirmation it worked.
Future<void> _syncAfterConnect() async { Future<void> _syncAfterConnect() async {
final s = _current; final s = _current;
try { try {
@ -96,19 +101,29 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
widget.appSettings.value = widget.appSettings.value.adopt( widget.appSettings.value = widget.appSettings.value.adopt(
run.appSettings, run.appSettings,
); );
if (mounted) { _show(
setState( StatusCue.ok(
() => _status = 'Connected and synced (merged '
'Connected and synced (merged ' '${run.syncResult.mergedDevices} device(s)). Your notes are '
'${run.syncResult.mergedDevices} device(s)). Your notes are ' 'up to date.',
'up to date.', ),
); );
}
} on Exception catch (e) { } on Exception catch (e) {
if (mounted) setState(() => _status = 'Connected, but sync failed: $e'); _show(
StatusCue(
Icons.sync_problem,
'Connected, but sync failed: $e',
error: true,
),
);
} }
} }
/// Shows [cue] as the screen's latest result, if still mounted.
void _show(StatusCue cue) {
if (mounted) setState(() => _status = cue);
}
Future<void> _test() async { Future<void> _test() async {
setState(() { setState(() {
_testing = true; _testing = true;
@ -123,13 +138,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
); );
try { try {
final ok = await client.canAccessRepo(); final ok = await client.canAccessRepo();
setState( _show(
() => _status = ok ok
? 'Connected — repo is reachable.' ? const StatusCue.ok('Connected — repo is reachable.')
: 'Could not access ${s.owner}/${s.repo}. Check token scope.', : StatusCue(
Icons.lock,
'Could not access ${s.owner}/${s.repo}. Check token scope.',
error: true,
),
); );
} on Exception catch (e) { } on Exception catch (e) {
setState(() => _status = 'Error: $e'); _show(StatusCue.failed('Error: $e'));
} finally { } finally {
client.close(); client.close();
if (mounted) setState(() => _testing = false); if (mounted) setState(() => _testing = false);
@ -145,29 +164,27 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Advanced sync (GitHub)')), appBar: AppBar(
title: const Icon(
Icons.cloud_sync,
semanticLabel: 'Advanced sync (GitHub)',
),
),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Text(
'Authorize in your browser — no token to paste. Syncs to '
'kuhyx/syncs by default.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: FilledButton.icon( child: IconButton.filled(
onPressed: _connectGitHub, onPressed: _connectGitHub,
icon: const Icon(Icons.login), icon: const Icon(Icons.login, semanticLabel: 'Connect GitHub'),
label: const Text('Connect GitHub'),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _owner, controller: _owner,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'GitHub owner', prefixIcon: Icon(Icons.person, semanticLabel: 'GitHub owner'),
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
@ -175,7 +192,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
TextField( TextField(
controller: _repo, controller: _repo,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Repository name', prefixIcon: Icon(Icons.folder, semanticLabel: 'Repository name'),
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
@ -183,8 +200,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
TextField( TextField(
controller: _clientId, controller: _clientId,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'OAuth App client id', prefixIcon: Icon(Icons.key, semanticLabel: 'OAuth App client id'),
helperText: 'Leave as the baked-in default unless self-hosting',
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
@ -193,15 +209,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
controller: _token, controller: _token,
obscureText: true, obscureText: true,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Access token (fallback)', prefixIcon: Icon(
helperText: 'Contents: read/write on the sync repo only', Icons.vpn_key,
semanticLabel: 'Access token (fallback)',
),
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
OutlinedButton.icon( IconButton.outlined(
onPressed: _testing ? null : _test, onPressed: _testing ? null : _test,
icon: _testing icon: _testing
? const SizedBox( ? const SizedBox(
@ -209,20 +227,21 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
height: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.wifi_tethering), : const Icon(
label: const Text('Test connection'), Icons.wifi_tethering,
semanticLabel: 'Test connection',
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
FilledButton.icon( IconButton.filled(
onPressed: _save, onPressed: _save,
icon: const Icon(Icons.save), icon: const Icon(Icons.save, semanticLabel: 'Save'),
label: const Text('Save'),
), ),
], ],
), ),
if (_status != null) ...[ if (_status != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text(_status!, style: Theme.of(context).textTheme.bodyMedium), StatusCueView(_status!),
], ],
], ],
), ),

View File

@ -9,12 +9,12 @@ library;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:todo/attachments/image_store_api.dart'; import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart'; import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/attachment_ref.dart'; import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/cue_snack.dart';
/// Lets a screen insert lines into whichever editor is currently mounted. /// Lets a screen insert lines into whichever editor is currently mounted.
class ImageAttachController { class ImageAttachController {
@ -73,11 +73,15 @@ Future<void> attachImages(
target.insert([for (final ref in refs) ref.imageMarkdown]); target.insert([for (final ref in refs) ref.imageMarkdown]);
if (!context.mounted) return; if (!context.mounted) return;
if (failure != null) { if (failure != null) {
showError(context, failure); showCue(context, Icons.cloud_off, failure, error: true);
} else if (notImages > 0) { } else if (notImages > 0) {
showError( // A broken-image glyph plus how many were refused.
showCue(
context, context,
Icons.broken_image_outlined,
notImages == 1 ? 'Not an image' : '$notImages files were not images', notImages == 1 ? 'Not an image' : '$notImages files were not images',
error: true,
count: notImages,
); );
} }
} }
@ -104,5 +108,7 @@ Future<void> attachFiles(
} }
} }
target.insert([for (final attachment in attached) attachment.markdown]); target.insert([for (final attachment in attached) attachment.markdown]);
if (failure != null && context.mounted) showError(context, failure); if (failure != null && context.mounted) {
showCue(context, Icons.cloud_off, failure, error: true);
}
} }

View File

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:todo/attachments/image_store_api.dart'; import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart'; import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/attachment_ref.dart'; import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/cue_snack.dart';
import 'package:todo/ui/images/file_opening.dart'; import 'package:todo/ui/images/file_opening.dart';
import 'package:todo/ui/images/image_embed.dart'; import 'package:todo/ui/images/image_embed.dart';
@ -51,7 +52,9 @@ class _FileEmbedState extends State<FileEmbed> {
Future<void> _open(ImageSource source) async { Future<void> _open(ImageSource source) async {
final failure = await widget.opening(source, widget.attachment.name); final failure = await widget.opening(source, widget.attachment.name);
if (failure != null && mounted) showError(context, failure); if (failure != null && mounted) {
showCue(context, Icons.block, failure, error: true);
}
} }
@override @override
@ -73,9 +76,10 @@ class _FileEmbedState extends State<FileEmbed> {
style: widget.compact ? theme.textTheme.bodySmall : null, style: widget.compact ? theme.textTheme.bodySmall : null,
); );
final pending = store.isPending(attachment.ref) final pending = store.isPending(attachment.ref)
? const Tooltip( ? const Icon(
message: kImageWaitingToUpload, Icons.cloud_upload_outlined,
child: Icon(Icons.cloud_upload_outlined, size: 18), size: 18,
semanticLabel: kImageWaitingToUpload,
) )
: null; : null;
final content = widget.compact final content = widget.compact
@ -104,15 +108,21 @@ class _FileEmbedState extends State<FileEmbed> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
name, name,
Text(detail, style: theme.textTheme.bodySmall), // Not downloaded: a download glyph; else the size.
if (size == null)
const Icon(Icons.cloud_download_outlined, size: 16)
else
Text(detail, style: theme.textTheme.bodySmall),
], ],
), ),
), ),
?pending, ?pending,
], ],
); );
// Spoken only: nothing paints this string.
final semanticLabel = 'Attached file ${attachment.name}, $detail';
return Semantics( return Semantics(
label: 'Attached file ${attachment.name}, $detail', label: semanticLabel,
button: size != null, button: size != null,
child: Material( child: Material(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,

View File

@ -33,8 +33,10 @@ class ImageEmbed extends StatelessWidget {
final store = ImageStoreScope.of(context); final store = ImageStoreScope.of(context);
final source = store.sourceFor(ref); final source = store.sourceFor(ref);
final picture = imageFor(source, fit: fit); final picture = imageFor(source, fit: fit);
// Spoken only: nothing paints this string.
const semanticLabel = 'Attached image';
return Semantics( return Semantics(
label: 'Attached image', label: semanticLabel,
button: source is! MissingImageSource, button: source is! MissingImageSource,
child: GestureDetector( child: GestureDetector(
onTap: source is MissingImageSource onTap: source is MissingImageSource
@ -52,9 +54,10 @@ class ImageEmbed extends StatelessWidget {
const Positioned( const Positioned(
right: 4, right: 4,
top: 4, top: 4,
child: Tooltip( child: Icon(
message: kImageWaitingToUpload, Icons.cloud_upload_outlined,
child: Icon(Icons.cloud_upload_outlined, size: 18), size: 18,
semanticLabel: kImageWaitingToUpload,
), ),
), ),
], ],
@ -85,7 +88,7 @@ Widget imageFor(ImageSource source, {required BoxFit fit}) {
}; };
} }
/// Grey box with [kImageNotDownloaded]. /// Grey box with a download glyph, spoken as [kImageNotDownloaded].
class ImagePlaceholder extends StatelessWidget { class ImagePlaceholder extends StatelessWidget {
/// Creates the placeholder. /// Creates the placeholder.
const ImagePlaceholder({super.key}); const ImagePlaceholder({super.key});
@ -98,10 +101,10 @@ class ImagePlaceholder extends StatelessWidget {
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text( child: Icon(
kImageNotDownloaded, Icons.cloud_download_outlined,
textAlign: TextAlign.center, color: theme.colorScheme.outline,
style: theme.textTheme.bodySmall, semanticLabel: kImageNotDownloaded,
), ),
), ),
), ),

View File

@ -6,10 +6,11 @@ library;
import 'dart:async'; import 'dart:async';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/confirm_delete.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/note_sheet_details.dart'; import 'package:todo/ui/note_sheet_details.dart';
import 'package:todo/ui/note_task_details.dart'; import 'package:todo/ui/note_task_details.dart';
@ -61,53 +62,54 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( // User content; untitled shows nothing, not a placeholder.
firstLine.isEmpty ? '(empty)' : firstLine, if (firstLine.isNotEmpty)
style: Theme.of(context).textTheme.titleMedium, Text(
maxLines: 2, firstLine,
overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium,
), maxLines: 2,
overflow: TextOverflow.ellipsis,
),
NoteSheetDetails(note: widget.note), NoteSheetDetails(note: widget.note),
const SizedBox(height: 16), const SizedBox(height: 16),
_EnumChips<Status>( GlyphChips<Status>(
label: 'Status', heading: kStatusHeadingIcon,
semanticLabel: 'Status',
values: Status.values, values: Status.values,
selected: {_status}, selected: {_status},
labelOf: (s) => s.label, glyphOf: statusGlyph,
onSelected: (s) { onTap: (s) {
setState(() => _status = s); setState(() => _status = s);
unawaited(_persist()); unawaited(_persist());
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_EnumChips<Priority>( GlyphChips<Priority>(
label: 'Priority', heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
values: Priority.values, values: Priority.values,
selected: {_priority}, selected: {_priority},
labelOf: (p) => p.label, glyphOf: (p) => priorityGlyph(context, p),
onSelected: (p) { onTap: (p) {
setState(() => _priority = p); setState(() => _priority = p);
unawaited(_persist()); unawaited(_persist());
}, },
), ),
NoteTaskDetails(note: widget.note), NoteTaskDetails(note: widget.note),
const SizedBox(height: 16), const SizedBox(height: 16),
OutlinedButton.icon( Align(
icon: const Icon(Icons.delete_outline), child: IconButton.outlined(
label: const Text('Delete note'), color: Theme.of(context).colorScheme.error,
style: OutlinedButton.styleFrom( icon: const Icon(
foregroundColor: Theme.of(context).colorScheme.error, Icons.delete_outline,
semanticLabel: 'Delete note',
),
onPressed: () async {
if (!await confirmDeleteNote(context)) return;
await widget.onDelete();
if (context.mounted) Navigator.of(context).pop();
},
), ),
onPressed: () async {
final confirmed = await confirmDestructive(
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
await widget.onDelete();
if (context.mounted) Navigator.of(context).pop();
},
), ),
], ],
), ),
@ -116,42 +118,3 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
); );
} }
} }
/// 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: 8),
Wrap(
spacing: 8,
children: [
for (final v in values)
ChoiceChip(
label: Text(labelOf(v)),
selected: selected.contains(v),
onSelected: (_) => onSelected(v),
),
],
),
],
);
}
}

View File

@ -9,19 +9,29 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Glyph for "created" (a note added), shared by the filter and sort menus.
const IconData kCreatedIcon = Icons.post_add;
/// Glyph for "last updated", shared by the filter and sort menus.
const IconData kUpdatedIcon = Icons.update;
/// A labelled date range shown as one preset menu plus a custom picker. /// A labelled date range shown as one preset menu plus a custom picker.
class DateRangeField extends StatelessWidget { class DateRangeField extends StatelessWidget {
/// Creates a labelled date-range control. /// Creates a labelled date-range control.
const DateRangeField({ const DateRangeField({
required this.label, required this.icon,
required this.semanticLabel,
required this.from, required this.from,
required this.to, required this.to,
required this.onChanged, required this.onChanged,
super.key, super.key,
}); });
/// Heading shown above the control (e.g. 'Created'). /// Glyph shown in the control's border in place of a written heading.
final String label; final IconData icon;
/// Spoken name of the control (e.g. 'Created'); never painted.
final String semanticLabel;
/// Start of the selected range, or null when unbounded. /// Start of the selected range, or null when unbounded.
final DateTime? from; final DateTime? from;
@ -86,15 +96,16 @@ class DateRangeField extends StatelessWidget {
// A popup menu rather than DropdownButton: re-picking "Custom…" while a // A popup menu rather than DropdownButton: re-picking "Custom…" while a
// custom range is shown must still reopen the picker. // custom range is shown must still reopen the picker.
return PopupMenuButton<_Preset>( return PopupMenuButton<_Preset>(
tooltip: label, // Empty, not the label: a tooltip is painted on long-press.
tooltip: '',
onSelected: (p) => _select(context, p), onSelected: (p) => _select(context, p),
itemBuilder: (_) => [ itemBuilder: (_) => [
for (final p in _Preset.values) for (final p in _Preset.values)
PopupMenuItem(value: p, child: Text(p.label)), PopupMenuItem(value: p, child: p.glyph()),
], ],
child: InputDecorator( child: InputDecorator(
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, prefixIcon: Icon(icon, semanticLabel: semanticLabel),
isDense: true, isDense: true,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@ -103,10 +114,9 @@ class DateRangeField extends StatelessWidget {
), ),
suffixIcon: const Icon(Icons.arrow_drop_down), suffixIcon: const Icon(Icons.arrow_drop_down),
), ),
child: Text( child: current == _Preset.custom
current == _Preset.custom ? _rangeLabel() : current.label, ? Text(_rangeLabel(), style: theme.textTheme.bodyMedium)
style: theme.textTheme.bodyMedium, : current.glyph(),
),
), ),
); );
} }
@ -131,8 +141,26 @@ enum _Preset {
const _Preset(this.label, this.days); const _Preset(this.label, this.days);
/// Spoken name only; what is painted is [glyph].
final String label; final String label;
final int? days; final int? days;
/// "Any" is an infinity sign, "today" the today-calendar, a span its day
/// count beside a range-calendar, "custom" the edit-calendar.
Widget glyph() => Semantics(
label: label,
excludeSemantics: true,
container: true,
child: switch (this) {
any => const Icon(Icons.all_inclusive),
today => const Icon(Icons.today),
custom => const Icon(Icons.edit_calendar),
week || month => Row(
mainAxisSize: MainAxisSize.min,
children: [const Icon(Icons.date_range), Text(' $days')],
),
},
);
static const List<_Preset> dayPresets = [today, week, month]; static const List<_Preset> dayPresets = [today, week, month];
} }

View File

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:todo/data/app_settings.dart'; import 'package:todo/data/app_settings.dart';
@ -8,6 +7,7 @@ import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart'; import 'package:todo/data/note_repository.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/attachment_picking.dart'; import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/confirm_delete.dart';
import 'package:todo/ui/image_attach.dart'; import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/image_input.dart'; import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/note_form.dart'; import 'package:todo/ui/note_form.dart';
@ -85,12 +85,7 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
_persist(_note.copyWith(text: text, updatedAt: DateTime.now())); _persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
Future<void> _delete() async { Future<void> _delete() async {
final confirmed = await confirmDestructive( if (!await confirmDeleteNote(context)) return;
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
await widget.repository.delete(_note.id); await widget.repository.delete(_note.id);
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
} }
@ -100,7 +95,8 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
final title = noteTitle(_note.text); final title = noteTitle(_note.text);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(title.isEmpty ? '(empty)' : title), // An untitled note shows no title rather than a written placeholder.
title: title.isEmpty ? null : Text(title),
actions: [ actions: [
AttachButton( AttachButton(
onFiles: _attachFiles, onFiles: _attachFiles,
@ -114,8 +110,10 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
onPressed: _copy, onPressed: _copy,
), ),
IconButton( IconButton(
tooltip: 'Delete note', icon: const Icon(
icon: const Icon(Icons.delete_outline), Icons.delete_outline,
semanticLabel: 'Delete note',
),
onPressed: _delete, onPressed: _delete,
), ),
], ],

View File

@ -1,8 +1,8 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/cue_snack.dart';
import 'package:todo/ui/image_attach.dart'; import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/markdown_view.dart'; import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor_chrome.dart'; import 'package:todo/ui/note_editor_chrome.dart';
@ -127,9 +127,12 @@ class _NoteEditorState extends State<NoteEditor> {
case NoteEditorMode.guided: case NoteEditorMode.guided:
// raw -> guided: only if the edited text still fits the template. // raw -> guided: only if the edited text still fits the template.
if (!_doc.tryAdoptGuided()) { if (!_doc.tryAdoptGuided()) {
showError( // The mode toggle staying on raw is the second channel.
showCue(
context, context,
Icons.rule,
"Text doesn't match the template — staying in raw", "Text doesn't match the template — staying in raw",
error: true,
); );
return; return;
} }

View File

@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/note_editor_mode.dart'; import 'package:todo/ui/note_editor_mode.dart';
import 'package:todo/ui/template_glyphs.dart';
/// Template dropdown above a segmented View / Guided / Raw selector. /// Template dropdown above a segmented View / Guided / Raw selector.
class NoteEditorChrome extends StatelessWidget { class NoteEditorChrome extends StatelessWidget {
@ -43,20 +44,20 @@ class NoteEditorChrome extends StatelessWidget {
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: template.id, initialValue: template.id,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Template', // A prefix, not a floating label: a floated icon sat on the
// border on top of the selected template's glyph.
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
isDense: true, isDense: true,
border: OutlineInputBorder(), border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
), ),
items: [ items: [
for (final t in NoteTemplate.all) for (final t in NoteTemplate.all)
DropdownMenuItem(value: t.id, child: Text(t.label)), DropdownMenuItem(value: t.id, child: templateGlyph(t)),
], ],
onChanged: (id) { onChanged: (id) {
if (id == null) return; if (id == null) return;
onTemplateSelected( onTemplateSelected(NoteTemplate.all.firstWhere((t) => t.id == id));
NoteTemplate.all.firstWhere((t) => t.id == id),
);
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -67,8 +68,7 @@ class NoteEditorChrome extends StatelessWidget {
segments: [ segments: [
const ButtonSegment( const ButtonSegment(
value: NoteEditorMode.preview, value: NoteEditorMode.preview,
icon: Icon(Icons.visibility_outlined), icon: Icon(Icons.visibility_outlined, semanticLabel: 'View'),
label: Text('View'),
), ),
// Guided is offered for any structured template; tapping it // Guided is offered for any structured template; tapping it
// on an empty draft opens the wizard, and is blocked at // on an empty draft opens the wizard, and is blocked at
@ -76,13 +76,11 @@ class NoteEditorChrome extends StatelessWidget {
if (!template.isFreeform) if (!template.isFreeform)
const ButtonSegment( const ButtonSegment(
value: NoteEditorMode.guided, value: NoteEditorMode.guided,
icon: Icon(Icons.checklist), icon: Icon(Icons.checklist, semanticLabel: 'Guided'),
label: Text('Guided'),
), ),
const ButtonSegment( const ButtonSegment(
value: NoteEditorMode.raw, value: NoteEditorMode.raw,
icon: Icon(Icons.notes), icon: Icon(Icons.notes, semanticLabel: 'Raw'),
label: Text('Raw'),
), ),
], ],
selected: {mode}, selected: {mode},

View File

@ -44,10 +44,8 @@ class NoteEditorRawField extends StatelessWidget {
textAlignVertical: TextAlignVertical.top, textAlignVertical: TextAlignVertical.top,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
decoration: const InputDecoration( // No hint: the cursor in an empty full-height box says "type".
border: InputBorder.none, decoration: const InputDecoration(border: InputBorder.none),
hintText: 'Write your idea…',
),
onChanged: (_) => onChanged(), onChanged: (_) => onChanged(),
), ),
), ),

View File

@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/motion.dart'; import 'package:todo/ui/motion.dart';
import 'package:todo/ui/template_glyphs.dart';
/// One step of the guided editor: label, guidance, input, and navigation. /// One step of the guided editor: label, guidance, input, and navigation.
class NoteEditorStepPage extends StatelessWidget { class NoteEditorStepPage extends StatelessWidget {
@ -66,8 +67,7 @@ class NoteEditorStepPage extends StatelessWidget {
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back, semanticLabel: 'Exit guided'),
tooltip: 'Exit guided',
onPressed: onExit, onPressed: onExit,
), ),
), ),
@ -96,16 +96,14 @@ class NoteEditorStepPage extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( // A glyph per section; its label (the note's `## heading`) and
section.isTitle ? 'title' : section.label, // the helper sentence are spoken only.
style: theme.textTheme.titleMedium, Icon(
), sectionIcon(section),
const SizedBox(height: 4), color: theme.colorScheme.primary,
Text( semanticLabel: section.isTitle
section.helper, ? section.helper
style: theme.textTheme.bodySmall?.copyWith( : '${section.label}: ${section.helper}',
color: theme.colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
@ -123,9 +121,10 @@ class NoteEditorStepPage extends StatelessWidget {
maxLines: null, maxLines: null,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.top, textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration( // No hintText: a hint is painted text. The section glyph
hintText: section.hint, // above already names the field.
border: const OutlineInputBorder(), decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true, isDense: true,
), ),
onChanged: (_) => onChanged(), onChanged: (_) => onChanged(),
@ -140,16 +139,22 @@ class NoteEditorStepPage extends StatelessWidget {
if (index > 0) if (index > 0)
TextButton( TextButton(
onPressed: () => onGoToStep(index - 1), onPressed: () => onGoToStep(index - 1),
child: const Text('Back'), child: const Icon(
Icons.navigate_before,
semanticLabel: 'Back',
),
), ),
const Spacer(), const Spacer(),
if (index < total - 1) if (index < total - 1)
FilledButton( FilledButton(
onPressed: () => onGoToStep(index + 1), onPressed: () => onGoToStep(index + 1),
child: const Text('Next'), child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
) )
else else
FilledButton(onPressed: onExit, child: const Text('Done')), FilledButton(
onPressed: onExit,
child: const Icon(Icons.check, semanticLabel: 'Done'),
),
], ],
), ),
), ),

View File

@ -13,6 +13,8 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/template_glyphs.dart';
/// Asks for a priority, then a template, then hands both back via [onStart]. /// Asks for a priority, then a template, then hands both back via [onStart].
class NoteEditorWizard extends StatefulWidget { class NoteEditorWizard extends StatefulWidget {
@ -48,7 +50,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final semanticLabel = 'Step ${_step + 1} of 2';
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -56,11 +58,24 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
Row( Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
tooltip: 'Cancel',
onPressed: widget.onCancel, onPressed: widget.onCancel,
), ),
Text('Step ${_step + 1} of 2', style: theme.textTheme.labelLarge), // Two step dots, the current one filled: position by shape.
Semantics(
label: semanticLabel,
child: ExcludeSemantics(
child: Row(
children: [
for (var i = 0; i < 2; i++)
Icon(
i == _step ? Icons.circle : Icons.circle_outlined,
size: 12,
),
],
),
),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -74,14 +89,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
DropdownButtonFormField<Priority>( DropdownButtonFormField<Priority>(
initialValue: _priority, initialValue: _priority,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Priority', prefixIcon: Icon(kPriorityHeadingIcon, semanticLabel: 'Priority'),
isDense: true, isDense: true,
border: OutlineInputBorder(), border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
), ),
items: [ items: [
for (final p in Priority.values) for (final p in Priority.values)
DropdownMenuItem(value: p, child: Text(p.label)), DropdownMenuItem(value: p, child: priorityGlyph(context, p)),
], ],
onChanged: (p) { onChanged: (p) {
if (p != null) setState(() => _priority = p); if (p != null) setState(() => _priority = p);
@ -92,7 +107,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: FilledButton( child: FilledButton(
onPressed: () => setState(() => _step = 1), onPressed: () => setState(() => _step = 1),
child: const Text('Next'), child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
), ),
), ),
]; ];
@ -104,14 +119,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: _template.id, initialValue: _template.id,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Template', prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
isDense: true, isDense: true,
border: OutlineInputBorder(), border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
), ),
items: [ items: [
for (final t in templates) for (final t in templates)
DropdownMenuItem(value: t.id, child: Text(t.label)), DropdownMenuItem(value: t.id, child: templateGlyph(t)),
], ],
onChanged: (id) { onChanged: (id) {
if (id == null) return; if (id == null) return;
@ -124,12 +139,12 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
children: [ children: [
TextButton( TextButton(
onPressed: () => setState(() => _step = 0), onPressed: () => setState(() => _step = 0),
child: const Text('Back'), child: const Icon(Icons.arrow_back, semanticLabel: 'Back'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FilledButton( FilledButton(
onPressed: () => widget.onStart(_priority, _template), onPressed: () => widget.onStart(_priority, _template),
child: const Text('Start'), child: const Icon(Icons.play_arrow, semanticLabel: 'Start'),
), ),
], ],
), ),

View File

@ -6,6 +6,25 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/enum_glyphs.dart';
/// Danger for high, the accent for medium, muted for low.
Color priorityColor(Priority p, ColorScheme scheme) => switch (p) {
Priority.high => scheme.error,
Priority.medium => scheme.primary,
Priority.low => scheme.outline,
};
/// Painted in place of an empty note's title: a mark, not a word.
const String kEmptyTitle = '…';
/// The glyph standing for [s] wherever a status is shown without words.
IconData statusIcon(Status s) => switch (s) {
Status.todo => Icons.radio_button_unchecked,
Status.inProgress => Icons.timelapse,
Status.done => Icons.check_circle_outline,
Status.abandoned => Icons.cancel_outlined,
};
/// A note as a grid cell. It fills whatever cell it is given. /// A note as a grid cell. It fills whatever cell it is given.
class NoteGridTile extends StatelessWidget { class NoteGridTile extends StatelessWidget {
@ -14,9 +33,13 @@ class NoteGridTile extends StatelessWidget {
required this.note, required this.note,
required this.onTap, required this.onTap,
required this.onActions, required this.onActions,
this.selected = false,
super.key, super.key,
}); });
/// Whether the note is part of the multi-selection: a tint and a check.
final bool selected;
/// The note this tile displays. /// The note this tile displays.
final Note note; final Note note;
@ -27,18 +50,7 @@ class NoteGridTile extends StatelessWidget {
final VoidCallback onActions; final VoidCallback onActions;
/// Danger for high, the accent for medium, muted for low. /// Danger for high, the accent for medium, muted for low.
Color accentFor(ColorScheme scheme) => switch (note.priority) { Color accentFor(ColorScheme scheme) => priorityColor(note.priority, scheme);
Priority.high => scheme.error,
Priority.medium => scheme.primary,
Priority.low => scheme.outline,
};
static IconData _statusIcon(Status s) => switch (s) {
Status.todo => Icons.radio_button_unchecked,
Status.inProgress => Icons.timelapse,
Status.done => Icons.check_circle_outline,
Status.abandoned => Icons.cancel_outlined,
};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -49,7 +61,7 @@ class NoteGridTile extends StatelessWidget {
final closed = final closed =
note.status == Status.done || note.status == Status.abandoned; note.status == Status.done || note.status == Status.abandoned;
return Material( return Material(
color: scheme.surfaceContainerHigh, color: selected ? scheme.secondaryContainer : scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md), borderRadius: BorderRadius.circular(AppRadius.md),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
@ -72,14 +84,30 @@ class NoteGridTile extends StatelessWidget {
children: [ children: [
// Expanded: a big text scale shortens the title, never overflows. // Expanded: a big text scale shortens the title, never overflows.
Expanded( Expanded(
child: Text( child: Row(
title.isEmpty ? '(empty)' : title, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: 3, children: [
overflow: TextOverflow.ellipsis, Expanded(
style: theme.textTheme.bodyMedium?.copyWith( child: Text(
color: closed ? scheme.onSurfaceVariant : null, title.isEmpty ? kEmptyTitle : title,
decoration: closed ? TextDecoration.lineThrough : null, maxLines: 3,
), overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: closed ? scheme.onSurfaceVariant : null,
decoration: closed
? TextDecoration.lineThrough
: null,
),
),
),
if (selected)
Icon(
Icons.check_circle,
size: 20,
color: scheme.primary,
semanticLabel: 'Selected',
),
],
), ),
), ),
if (note.repoName case final repo?) if (note.repoName case final repo?)
@ -91,25 +119,20 @@ class NoteGridTile extends StatelessWidget {
), ),
Row( Row(
children: [ children: [
// Shape + the tile's accent border carry status/priority;
// the enum words survive only as spoken labels.
Icon( Icon(
_statusIcon(note.status), statusIcon(note.status),
size: 14, size: 14,
color: scheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), semanticLabel: note.status.label,
const SizedBox(width: AppSpacing.xs),
Flexible(
child: Text(
note.status.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: cue,
),
), ),
const Spacer(), const Spacer(),
Text( Icon(
note.priority.label, priorityIcon(note.priority),
maxLines: 1, size: 14,
style: cue?.copyWith(color: accentFor(scheme)), color: accentFor(scheme),
semanticLabel: note.priority.label,
), ),
], ],
), ),

View File

@ -8,24 +8,30 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/ui/enum_glyphs.dart';
/// A compact labelled dropdown for picking an enum value (priority/status). /// A compact dropdown for picking an enum value (priority/status), headed by
/// a glyph and showing each value as its glyph.
/// ///
/// Generic over the enum type [T] so the same control drives both pickers /// Generic over the enum type [T] so the same control drives both pickers
/// without duplication; [labelOf] maps a value to its display string. /// without duplication; [glyphOf] maps a value to its glyph.
class MetaDropdown<T> extends StatelessWidget { class MetaDropdown<T> extends StatelessWidget {
/// Creates a dropdown showing [value] out of [values]. /// Creates a dropdown showing [value] out of [values].
const MetaDropdown({ const MetaDropdown({
required this.label, required this.heading,
required this.semanticLabel,
required this.value, required this.value,
required this.values, required this.values,
required this.labelOf, required this.glyphOf,
required this.onChanged, required this.onChanged,
super.key, super.key,
}); });
/// Label shown above the control. /// Glyph in the control's border in place of a written heading.
final String label; final IconData heading;
/// Spoken name of the control; never painted.
final String semanticLabel;
/// Currently selected value. /// Currently selected value.
final T value; final T value;
@ -33,8 +39,8 @@ class MetaDropdown<T> extends StatelessWidget {
/// Every selectable value. /// Every selectable value.
final List<T> values; final List<T> values;
/// Maps a value to its display string. /// Maps a value to its glyph.
final String Function(T) labelOf; final Widget Function(T) glyphOf;
/// Called with the newly picked value. /// Called with the newly picked value.
final ValueChanged<T> onChanged; final ValueChanged<T> onChanged;
@ -43,7 +49,7 @@ class MetaDropdown<T> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InputDecorator( return InputDecorator(
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, prefixIcon: Icon(heading, semanticLabel: semanticLabel),
isDense: true, isDense: true,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@ -55,7 +61,7 @@ class MetaDropdown<T> extends StatelessWidget {
isExpanded: true, isExpanded: true,
items: [ items: [
for (final v in values) for (final v in values)
DropdownMenuItem<T>(value: v, child: Text(labelOf(v))), DropdownMenuItem<T>(value: v, child: glyphOf(v)),
], ],
onChanged: (v) { onChanged: (v) {
if (v != null) onChanged(v); if (v != null) onChanged(v);
@ -98,20 +104,22 @@ class NoteMetaRow extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: MetaDropdown<Priority>( child: MetaDropdown<Priority>(
label: 'Priority', heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
value: priority, value: priority,
values: Priority.values, values: Priority.values,
labelOf: (p) => p.label, glyphOf: (p) => priorityGlyph(context, p),
onChanged: onPriorityChanged, onChanged: onPriorityChanged,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: MetaDropdown<Status>( child: MetaDropdown<Status>(
label: 'Status', heading: kStatusHeadingIcon,
semanticLabel: 'Status',
value: status, value: status,
values: Status.values, values: Status.values,
labelOf: (s) => s.label, glyphOf: statusGlyph,
onChanged: onStatusChanged, onChanged: onStatusChanged,
), ),
), ),

View File

@ -0,0 +1,86 @@
/// Which notes of the list are selected, and whether a tap selects.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:todo/data/note.dart';
/// The notes list's multi-select state.
///
/// Holds ids, not notes, so a selection survives the list re-emitting
/// (a bulk status change rebuilds every tile). Selection order is kept, so
/// an assign appends the notes in the order they were picked.
class NoteSelection extends ChangeNotifier {
final Set<String> _ids = {};
bool _active = false;
String? _anchor;
/// Whether a plain tap selects rather than opens.
bool get active => _active;
/// The selected ids, in the order they were picked.
Set<String> get ids => Set.unmodifiable(_ids);
/// Whether [id] is selected.
bool contains(String id) => _ids.contains(id);
/// Enters selection mode, or leaves it and drops the selection.
void toggleMode() {
if (_active) return clear();
_active = true;
notifyListeners();
}
/// Flips [id] in or out of the selection; enters the mode.
void toggle(String id) {
_active = true;
if (!_ids.remove(id)) _ids.add(id);
_anchor = id;
notifyListeners();
}
/// Selects every note of [order] from the last toggled one to [id]
/// (Shift-click); without an anchor in [order] it is a [toggle].
void extendTo(String id, List<Note> order) {
final from = order.indexWhere((n) => n.id == _anchor);
final to = order.indexWhere((n) => n.id == id);
if (from < 0 || to < 0) return toggle(id);
final (lo, hi) = from < to ? (from, to) : (to, from);
_ids.addAll(order.sublist(lo, hi + 1).map((n) => n.id));
_anchor = id;
notifyListeners();
}
/// Keeps only the selected ids in [keep] (e.g. the notes an assign
/// refused); an empty result is a [clear].
void retain(Set<String> keep) {
_ids.retainWhere(keep.contains);
if (_ids.isEmpty) return clear();
notifyListeners();
}
/// Drops the selection and leaves the mode.
void clear() {
_ids.clear();
_active = false;
_anchor = null;
notifyListeners();
}
/// Handles a tap on [note] of the list [order]; false means "open it".
///
/// Shift extends, Ctrl/Cmd toggles even outside the mode (desktop), and in
/// the mode a plain tap toggles.
bool handleTap(Note note, List<Note> order) {
final keys = HardwareKeyboard.instance;
if (keys.isShiftPressed) {
extendTo(note.id, order);
} else if (_active || keys.isControlPressed || keys.isMetaPressed) {
toggle(note.id);
} else {
return false;
}
return true;
}
}

View File

@ -39,12 +39,25 @@ class NoteSheetDetails extends StatelessWidget {
Text(body, style: theme.bodyMedium), Text(body, style: theme.bodyMedium),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Wrap(
'Edited ${_stamp(note.updatedAt)}' spacing: 12,
' · Created ${_stamp(note.createdAt)}', children: [
style: theme.bodySmall, _dated(Icons.history, 'Edited', note.updatedAt, theme.bodySmall),
_dated(Icons.add, 'Created', note.createdAt, theme.bodySmall),
],
), ),
], ],
); );
} }
/// A glyph (edited = history, created = plus) before its timestamp.
Widget _dated(IconData icon, String spoken, DateTime t, TextStyle? style) =>
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, semanticLabel: spoken),
const SizedBox(width: 4),
Text(_stamp(t), style: style),
],
);
} }

View File

@ -12,20 +12,27 @@ class NoteTaskDetails extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme; final theme = Theme.of(context).textTheme;
// A glyph per field in place of its written name (kept spoken only);
// the values are content and stay as typed.
final rows = [ final rows = [
('Repo', note.repo), (Icons.folder_outlined, 'Repo', note.repo),
('Done check', note.doneCheck), (Icons.fact_check_outlined, 'Done check', note.doneCheck),
('By', note.by), (Icons.person_outline, 'By', note.by),
('Evidence', note.evidence), (Icons.verified_outlined, 'Evidence', note.evidence),
].where((row) => row.$2 != null); ].where((row) => row.$3 != null);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final (label, value) in rows) ...[ for (final (icon, label, value) in rows) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text(label, style: theme.labelLarge), Row(
const SizedBox(height: 4), crossAxisAlignment: CrossAxisAlignment.start,
Text(value!, style: theme.bodyMedium), children: [
Icon(icon, size: 20, semanticLabel: label),
const SizedBox(width: 8),
Expanded(child: Text(value!, style: theme.bodyMedium)),
],
),
], ],
], ],
); );

View File

@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/note_grid_tile.dart';
/// One row in the notes list: title, metadata line, and an actions button. /// One row in the notes list: title, metadata line, and an actions button.
class NoteTile extends StatelessWidget { class NoteTile extends StatelessWidget {
@ -16,9 +18,13 @@ class NoteTile extends StatelessWidget {
required this.note, required this.note,
required this.onTap, required this.onTap,
required this.onActions, required this.onActions,
this.selected = false,
super.key, super.key,
}); });
/// Whether the row is part of the multi-selection: highlighted + a check.
final bool selected;
/// The note this row displays. /// The note this row displays.
final Note note; final Note note;
@ -33,23 +39,19 @@ class NoteTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final firstLine = noteTitle(note.text); final firstLine = noteTitle(note.text);
// 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)}',
?note.repoName,
].join(' · ');
return ListTile( return ListTile(
selected: selected,
leading: selected
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
: null,
title: Text( title: Text(
firstLine.isEmpty ? '(empty)' : firstLine, firstLine.isEmpty ? kEmptyTitle : firstLine,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text(meta), subtitle: _meta(context),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert, semanticLabel: 'Quick actions'),
tooltip: 'Quick actions',
onPressed: onActions, onPressed: onActions,
), ),
onTap: onTap, onTap: onTap,
@ -57,14 +59,28 @@ class NoteTile extends StatelessWidget {
); );
} }
/// Compact relative time like "2m ago" for the list subtitle. /// Status and priority glyphs, the edit age, and the repo (content).
String _relative(DateTime t) { /// Every note has a status and a priority, so both are always shown.
final d = DateTime.now().difference(t); Widget _meta(BuildContext context) => Row(
if (d.inMinutes < 1) return 'just now'; children: [
if (d.inHours < 1) return '${d.inMinutes}m ago'; statusGlyph(note.status, size: 16),
if (d.inDays < 1) return '${d.inHours}h ago'; const SizedBox(width: 6),
return '${d.inDays}d ago'; priorityGlyph(context, note.priority, size: 16),
} const SizedBox(width: 6),
const Icon(Icons.history, size: 16, semanticLabel: 'Edited'),
Text(relativeAge(note.updatedAt)),
if (note.repoName case final repo?) ...[
const SizedBox(width: 6),
Flexible(child: Text(repo, overflow: TextOverflow.ellipsis)),
],
],
);
} }
/// Bottom sheet for editing one note's priority/status or deleting it. /// Compact age like "2m" / "3h" / "4d": a number plus a unit symbol.
String relativeAge(DateTime t) {
final d = DateTime.now().difference(t);
if (d.inHours < 1) return '${d.inMinutes}m';
if (d.inDays < 1) return '${d.inHours}h';
return '${d.inDays}d';
}

View File

@ -0,0 +1,37 @@
/// The selection bar's assign flow: pick a repo, move the notes into it.
///
/// Data-layer only ([assignToRepo]); the UI never reaches the board, so an
/// assign never starts a task.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_assign.dart';
import 'package:todo/ui/note_selection.dart';
import 'package:todo/ui/repo_picker_sheet.dart';
/// Asks for a repo and assigns the selected plain notes to it, in
/// selection order. True when some notes were refused (already in a repo);
/// those stay selected, the moved ones leave the selection.
Future<bool> assignSelection(
BuildContext context,
NoteRepository repository,
NoteSelection selection,
) async {
final all = await repository.listNotes();
final repos = {for (final n in all) ?n.repo}.toList()..sort();
if (!context.mounted) return false;
final repo = await showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => RepoPickerSheet(repos: repos),
);
if (repo == null) return false;
final byId = {for (final n in all) n.id: n};
final chosen = [for (final id in selection.ids) ?byId[id]];
final refused = await assignToRepo(repository, chosen, repo);
selection.retain({for (final n in refused) n.id});
return refused.isNotEmpty;
}

View File

@ -10,6 +10,7 @@ import 'package:flutter/material.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';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/note_date_range_field.dart'; import 'package:todo/ui/note_date_range_field.dart';
import 'package:todo/ui/notes_list_defaults.dart'; import 'package:todo/ui/notes_list_defaults.dart';
@ -66,7 +67,6 @@ class _FilterSheetState extends State<FilterSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
@ -82,33 +82,38 @@ class _FilterSheetState extends State<FilterSheet> {
children: [ children: [
Row( Row(
children: [ children: [
Text('Filters', style: theme.textTheme.titleLarge), const Icon(Icons.filter_list, semanticLabel: 'Filters'),
const Spacer(), const Spacer(),
TextButton( TextButton(
onPressed: _clearAll, onPressed: _clearAll,
child: const Text('Clear all'), child: const Icon(Icons.clear, semanticLabel: 'Clear all'),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_MultiChips<Status>( GlyphChips<Status>(
label: 'Status', multi: true,
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
values: Status.values, values: Status.values,
selected: _statuses, selected: _statuses,
labelOf: (s) => s.label, glyphOf: statusGlyph,
onToggle: (s) => _toggle(_statuses, s), onTap: (s) => _toggle(_statuses, s),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_MultiChips<Priority>( GlyphChips<Priority>(
label: 'Priority', multi: true,
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
values: Priority.values, values: Priority.values,
selected: _priorities, selected: _priorities,
labelOf: (p) => p.label, glyphOf: (p) => priorityGlyph(context, p),
onToggle: (p) => _toggle(_priorities, p), onTap: (p) => _toggle(_priorities, p),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
DateRangeField( DateRangeField(
label: 'Created', icon: kCreatedIcon,
semanticLabel: 'Created',
from: _createdFrom, from: _createdFrom,
to: _createdTo, to: _createdTo,
onChanged: (from, to) => setState(() { onChanged: (from, to) => setState(() {
@ -118,7 +123,8 @@ class _FilterSheetState extends State<FilterSheet> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
DateRangeField( DateRangeField(
label: 'Last updated', icon: kUpdatedIcon,
semanticLabel: 'Last updated',
from: _updatedFrom, from: _updatedFrom,
to: _updatedTo, to: _updatedTo,
onChanged: (from, to) => setState(() { onChanged: (from, to) => setState(() {
@ -129,7 +135,7 @@ class _FilterSheetState extends State<FilterSheet> {
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton( FilledButton(
onPressed: () => Navigator.of(context).pop(_build()), onPressed: () => Navigator.of(context).pop(_build()),
child: const Text('Apply'), child: const Icon(Icons.check, semanticLabel: 'Apply'),
), ),
], ],
), ),
@ -139,45 +145,6 @@ class _FilterSheetState extends State<FilterSheet> {
} }
} }
/// 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: 8),
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. /// A date-range control offering quick presets plus a custom range picker.
/// ///
/// Reports the chosen [from]/[to] (day granularity, both inclusive) back to /// Reports the chosen [from]/[to] (day granularity, both inclusive) back to

View File

@ -26,9 +26,13 @@ class NotesGrid extends StatelessWidget {
required this.notes, required this.notes,
required this.onOpen, required this.onOpen,
required this.onActions, required this.onActions,
required this.isSelected,
super.key, super.key,
}); });
/// Whether the note with this id is in the multi-selection.
final bool Function(String id) isSelected;
/// Notes in display order. /// Notes in display order.
final List<Note> notes; final List<Note> notes;
@ -42,6 +46,7 @@ class NotesGrid extends StatelessWidget {
return NoteGridTile( return NoteGridTile(
key: ValueKey(note.id), key: ValueKey(note.id),
note: note, note: note,
selected: isSelected(note.id),
onTap: () => onOpen(note), onTap: () => onOpen(note),
onActions: () => onActions(note), onActions: () => onActions(note),
); );

View File

@ -16,9 +16,13 @@ class NotesListBody extends StatelessWidget {
required this.notes, required this.notes,
required this.onOpen, required this.onOpen,
required this.onActions, required this.onActions,
required this.isSelected,
super.key, super.key,
}); });
/// Whether the note with this id is in the multi-selection.
final bool Function(String id) isSelected;
/// Notes in display order. /// Notes in display order.
final List<Note> notes; final List<Note> notes;
@ -32,13 +36,19 @@ class NotesListBody extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
? NotesGrid(notes: notes, onOpen: onOpen, onActions: onActions) ? NotesGrid(
notes: notes,
onOpen: onOpen,
onActions: onActions,
isSelected: isSelected,
)
: ListView.separated( : ListView.separated(
itemCount: notes.length, itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => NoteTile( itemBuilder: (context, i) => NoteTile(
key: ValueKey(notes[i].id), key: ValueKey(notes[i].id),
note: notes[i], note: notes[i],
selected: isSelected(notes[i].id),
onTap: () => onOpen(notes[i]), onTap: () => onOpen(notes[i]),
onActions: () => onActions(notes[i]), onActions: () => onActions(notes[i]),
), ),

View File

@ -5,13 +5,19 @@ import 'package:flutter/material.dart';
import 'package:todo/data/app_settings.dart'; import 'package:todo/data/app_settings.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';
import 'package:todo/ui/empty_notes_cue.dart';
import 'package:todo/ui/note_selection.dart';
import 'package:todo/ui/notes_assign_flow.dart';
import 'package:todo/ui/notes_filter_sheet.dart'; import 'package:todo/ui/notes_filter_sheet.dart';
import 'package:todo/ui/notes_list_body.dart'; import 'package:todo/ui/notes_list_body.dart';
import 'package:todo/ui/notes_list_defaults.dart'; import 'package:todo/ui/notes_list_defaults.dart';
import 'package:todo/ui/notes_list_navigation.dart'; import 'package:todo/ui/notes_list_navigation.dart';
import 'package:todo/ui/notes_selection_bar.dart';
export 'package:todo/ui/notes_list_defaults.dart'; export 'package:todo/ui/notes_list_defaults.dart';
part 'notes_list_screen_select.dart';
/// Searchable, filterable, sortable list of stored/synced notes. /// Searchable, filterable, sortable list of stored/synced notes.
/// ///
/// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen /// The heavy lifting (WHERE/ORDER BY) lives in [NoteRepository]; this screen
@ -53,25 +59,46 @@ class _NotesListScreenState extends State<NotesListScreen> {
/// Memoised stream; only replaced when [_sort]/[_filter] actually change. /// Memoised stream; only replaced when [_sort]/[_filter] actually change.
late Stream<List<Note>> _stream; late Stream<List<Note>> _stream;
/// Multi-select; any change rebuilds the screen (tiles + bottom bar).
late final _selection = NoteSelection()..addListener(() => setState(() {}));
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter); _stream = _watch();
} }
@override @override
void dispose() { void dispose() {
_debounce?.cancel(); _debounce?.cancel();
_searchController.dispose(); _searchController.dispose();
_selection.dispose();
super.dispose(); super.dispose();
} }
/// Rebuilds the watch stream for the current sort + filter. Call only /// Rebuilds the watch stream for the current sort + filter. Call only
/// from handlers that change those, never from [build]. /// from handlers that change those, never from [build].
void _applyState() { void _applyState() {
setState(() { setState(() => _stream = _watch());
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter); }
});
/// The query for the current sort + filter, minus Claude-made notes unless
/// this device's eye toggle is on.
Stream<List<Note>> _watch() => widget.repository.watchNotes(
sort: _sort,
filter: _filter.copyWith(
hideClaude: !widget.appSettings.value.showClaudeNotes,
),
);
Future<void> _toggleClaudeNotes() async {
final settings = widget.appSettings.value;
final next = await settings.withShowClaudeNotes(
value: !settings.showClaudeNotes,
);
if (!mounted) return;
widget.appSettings.value = next;
_applyState();
} }
void _onSearchChanged(String value) { void _onSearchChanged(String value) {
@ -108,29 +135,41 @@ class _NotesListScreenState extends State<NotesListScreen> {
final badgeCount = userFacetCount(_filter); final badgeCount = userFacetCount(_filter);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Notes'), title: const Icon(Icons.note, semanticLabel: 'Notes'),
actions: [ actions: [
_selectButton(),
// Eye, no label: open = Claude-made notes shown, slashed = hidden.
IconButton(
icon: Icon(
widget.appSettings.value.showClaudeNotes
? Icons.visibility
: Icons.visibility_off,
semanticLabel: 'Claude notes',
),
onPressed: _toggleClaudeNotes,
),
PopupMenuButton<NoteSort>( PopupMenuButton<NoteSort>(
tooltip: 'Sort', // Empty, not omitted: null falls back to the painted "Show menu".
icon: const Icon(Icons.sort), tooltip: '',
icon: const Icon(Icons.sort, semanticLabel: 'Sort'),
initialValue: _sort, initialValue: _sort,
onSelected: _setSort, onSelected: _setSort,
itemBuilder: (_) => const [ itemBuilder: (_) => const [
PopupMenuItem( PopupMenuItem(
value: NoteSort.modifiedDesc, value: NoteSort.modifiedDesc,
child: Text('Last updated'), child: Icon(Icons.update, semanticLabel: 'Last updated'),
), ),
PopupMenuItem( PopupMenuItem(
value: NoteSort.createdDesc, value: NoteSort.createdDesc,
child: Text('Newest created'), child: Icon(Icons.post_add, semanticLabel: 'Newest created'),
), ),
PopupMenuItem( PopupMenuItem(
value: NoteSort.alphabetical, value: NoteSort.alphabetical,
child: Text('Alphabetical'), child: Icon(Icons.sort_by_alpha, semanticLabel: 'Alphabetical'),
), ),
PopupMenuItem( PopupMenuItem(
value: NoteSort.priorityDesc, value: NoteSort.priorityDesc,
child: Text('Priority'), child: Icon(Icons.flag_outlined, semanticLabel: 'Priority'),
), ),
], ],
), ),
@ -144,8 +183,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
label: Text('$badgeCount'), label: Text('$badgeCount'),
offset: const Offset(-8, 4), offset: const Offset(-8, 4),
child: IconButton( child: IconButton(
tooltip: 'Filter', icon: const Icon(Icons.filter_list, semanticLabel: 'Filter'),
icon: const Icon(Icons.filter_list),
onPressed: _openFilters, onPressed: _openFilters,
), ),
), ),
@ -161,15 +199,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search notes…',
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),
isDense: true, isDense: true,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
suffixIcon: _searchController.text.isEmpty suffixIcon: _searchController.text.isEmpty
? null ? null
: IconButton( : IconButton(
tooltip: 'Clear search', icon: const Icon(
icon: const Icon(Icons.clear), Icons.clear,
semanticLabel: 'Clear search',
),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_onSearchChanged(''); _onSearchChanged('');
@ -186,27 +225,21 @@ class _NotesListScreenState extends State<NotesListScreen> {
final notes = snapshot.data ?? const <Note>[]; final notes = snapshot.data ?? const <Note>[];
if (notes.isEmpty) { if (notes.isEmpty) {
return Center( return Center(
child: Text( child: EmptyNotesCue(filtered: !_filter.isEmpty),
_filter.isEmpty
? 'No notes yet'
: 'No notes match these filters',
),
); );
} }
void open(Note n) =>
openNote(context, n, widget.repository, widget.appSettings);
void actions(Note n) =>
openNoteActions(context, n, widget.repository);
return NotesListBody( return NotesListBody(
notes: notes, notes: notes,
onOpen: open, onOpen: (n) => _tap(n, notes),
onActions: actions, onActions: _longPress,
isSelected: _selection.contains,
); );
}, },
), ),
), ),
], ],
), ),
bottomNavigationBar: _selectionBar(),
); );
} }
} }

View File

@ -0,0 +1,43 @@
/// The notes list's multi-select wiring: the app-bar toggle, taps, and the
/// bottom action bar.
///
/// A `part` of `notes_list_screen.dart` because it reads the screen's
/// private selection and repository; split out for file size.
part of 'notes_list_screen.dart';
extension _Selecting on _NotesListScreenState {
/// App-bar toggle into and out of selection mode (tinted while on).
Widget _selectButton() => IconButton(
key: const ValueKey('select-mode'),
isSelected: _selection.active,
onPressed: _selection.toggleMode,
icon: const Icon(Icons.checklist, semanticLabel: 'Select'),
);
/// A tap on [note] of [notes]: selects in the mode or with Shift/Ctrl,
/// else opens it.
void _tap(Note note, List<Note> notes) {
if (_selection.handleTap(note, notes)) return;
unawaited(openNote(context, note, widget.repository, widget.appSettings));
}
/// A long-press: toggles in the mode, else the per-note sheet.
void _longPress(Note note) {
if (_selection.active) return _selection.toggle(note.id);
unawaited(openNoteActions(context, note, widget.repository));
}
/// The bar, only while something is selected. Status and priority are
/// ONE store write for the whole selection ([NoteRepository.bulkUpdate]).
Widget? _selectionBar() {
if (_selection.ids.isEmpty) return null;
final repo = widget.repository;
return NotesSelectionBar(
onStatus: (s) => unawaited(repo.bulkUpdate(_selection.ids, status: s)),
onPriority: (p) =>
unawaited(repo.bulkUpdate(_selection.ids, priority: p)),
onAssign: () => assignSelection(context, repo, _selection),
onClear: _selection.clear,
);
}
}

View File

@ -0,0 +1,128 @@
/// The icon-only action bar shown while notes are selected.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/note_grid_tile.dart';
/// How long the assign icon shows the refusal block.
const kRefusalFlash = Duration(milliseconds: 1500);
/// Set status, set priority, assign to repo, clear: icons only.
///
/// Menus pass `tooltip: ''` because a tooltip is visible text; the icons
/// carry semantic labels instead.
class NotesSelectionBar extends StatefulWidget {
/// Creates the bar; every callback acts on the whole selection.
const NotesSelectionBar({
required this.onStatus,
required this.onPriority,
required this.onAssign,
required this.onClear,
super.key,
});
/// Sets every selected note's status.
final ValueChanged<Status> onStatus;
/// Sets every selected note's priority.
final ValueChanged<Priority> onPriority;
/// Runs the assign flow; true when some notes were refused.
final Future<bool> Function() onAssign;
/// Drops the selection.
final VoidCallback onClear;
@override
State<NotesSelectionBar> createState() => _NotesSelectionBarState();
}
class _NotesSelectionBarState extends State<NotesSelectionBar> {
Timer? _flash;
@override
void dispose() {
_flash?.cancel();
super.dispose();
}
Future<void> _assign() async {
if (!await widget.onAssign() || !mounted) return;
_flash?.cancel();
setState(() => _flash = Timer(kRefusalFlash, () => setState(() {})));
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final refused = _flash?.isActive ?? false;
return Material(
key: const ValueKey('selection-bar'),
color: scheme.surfaceContainer,
child: SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
PopupMenuButton<Status>(
key: const ValueKey('bulk-status'),
tooltip: '',
icon: const Icon(Icons.task_alt, semanticLabel: 'Set status'),
onSelected: widget.onStatus,
itemBuilder: (_) => [
for (final s in Status.values)
PopupMenuItem(
key: ValueKey('status-${s.name}'),
value: s,
child: Icon(statusIcon(s), semanticLabel: s.label),
),
],
),
PopupMenuButton<Priority>(
key: const ValueKey('bulk-priority'),
tooltip: '',
icon: const Icon(
Icons.flag_outlined,
semanticLabel: 'Set priority',
),
onSelected: widget.onPriority,
itemBuilder: (_) => [
for (final p in Priority.values.reversed)
PopupMenuItem(
key: ValueKey('priority-${p.name}'),
value: p,
child: Icon(
Icons.flag,
color: priorityColor(p, scheme),
semanticLabel: p.label,
),
),
],
),
IconButton(
key: const ValueKey('bulk-assign'),
onPressed: _assign,
icon: Icon(
refused ? Icons.block : Icons.drive_file_move_outline,
color: refused ? scheme.error : null,
semanticLabel: 'Assign to repo',
),
),
IconButton(
key: const ValueKey('bulk-clear'),
onPressed: widget.onClear,
icon: const Icon(
Icons.deselect,
semanticLabel: 'Clear selection',
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,97 @@
/// Bottom sheet picking the repo selected notes are assigned to.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/repo_assign.dart';
/// The repos already on notes, plus a field for a new `kuhyx/<name>`.
///
/// Pops with the chosen `owner/name`; dismissing it pops null. The field
/// starts at `kuhyx/` (content, not a label) and refuses anything that is
/// not `owner/name` with an error icon rather than words.
class RepoPickerSheet extends StatefulWidget {
/// Creates the picker listing [repos].
const RepoPickerSheet({required this.repos, super.key});
/// Distinct repos already present on notes, in display order.
final List<String> repos;
@override
State<RepoPickerSheet> createState() => _RepoPickerSheetState();
}
class _RepoPickerSheetState extends State<RepoPickerSheet> {
final _field = TextEditingController(text: 'kuhyx/');
bool _bad = false;
@override
void dispose() {
_field.dispose();
super.dispose();
}
void _submit() {
final value = _field.text.trim();
if (!isRepoName(value)) return setState(() => _bad = true);
Navigator.of(context).pop(value);
}
@override
Widget build(BuildContext context) {
return Padding(
key: const ValueKey('repo-picker'),
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: ListView(
shrinkWrap: true,
children: [
for (final repo in widget.repos)
ListTile(
key: ValueKey('repo-$repo'),
leading: const Icon(Icons.folder_outlined),
title: Text(repo),
onTap: () => Navigator.of(context).pop(repo),
),
],
),
),
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
key: const ValueKey('repo-new'),
controller: _field,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
onChanged: (_) {
if (_bad) setState(() => _bad = false);
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.create_new_folder_outlined),
error: _bad
? const Icon(
Icons.error_outline,
size: 16,
semanticLabel: 'Not owner/name',
)
: null,
suffixIcon: IconButton(
key: const ValueKey('repo-new-submit'),
onPressed: _submit,
icon: const Icon(Icons.check, semanticLabel: 'Use repo'),
),
),
),
),
],
),
),
);
}
}

View File

@ -105,11 +105,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// session is available, best-effort mirrors it so the choice follows the /// session is available, best-effort mirrors it so the choice follows the
/// user to their other devices. /// user to their other devices.
/// ///
/// The local write always lands — a signed-in-as-wrong-account device /// The local write always lands: a [FirebaseAuthError] from opening the
/// throwing [FirebaseAuthError] out of `openFirebase()` must not take the /// client (wrong account) is caught on its own, before the write.
/// toggle down with it, so opening the client has its own try/catch,
/// separate from (and preceding) the try that guards the local write and
/// mirror push.
Future<void> _setAdvancedMode(bool value) async { Future<void> _setAdvancedMode(bool value) async {
final analytics = widget.analytics; final analytics = widget.analytics;
if (analytics != null) { if (analytics != null) {
@ -206,7 +203,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Settings')), appBar: AppBar(
title: const Icon(Icons.settings, semanticLabel: 'Settings'),
),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@ -214,33 +213,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
valueListenable: widget.appSettings, valueListenable: widget.appSettings,
builder: (context, settings, _) => SwitchListTile( builder: (context, settings, _) => SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Enable advanced'), secondary: const Icon(Icons.tune, semanticLabel: _advanced),
subtitle: const Text(
'Priority/status, templates, view modes, and sync details '
'in the capture screen',
),
value: settings.advancedMode, value: settings.advancedMode,
onChanged: (value) => unawaited(_setAdvancedMode(value)), onChanged: (value) => unawaited(_setAdvancedMode(value)),
), ),
), ),
const Divider(), const Divider(),
ListTile( _link(
contentPadding: EdgeInsets.zero, const Icon(Icons.sync, semanticLabel: 'Sync settings: Firebase'),
title: const Text('Sync settings'), _openSyncSettings,
subtitle: const Text('Firebase sync and notes backup'),
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openSyncSettings()),
), ),
CloudImagesTile(httpClient: widget.httpClient), CloudImagesTile(httpClient: widget.httpClient),
ListTile( _link(
contentPadding: EdgeInsets.zero, Icon(
title: const Text('Advanced sync (GitHub)'), Icons.cloud_sync,
subtitle: const Text('Cutover mirror — not recommended'), color: Theme.of(context).colorScheme.outline,
trailing: const Icon(Icons.chevron_right), semanticLabel: 'Advanced sync (GitHub): not recommended',
onTap: () => unawaited(_openGitHubMirror()), ),
_openGitHubMirror,
), ),
], ],
), ),
); );
} }
// Spoken only: what the advanced switch turns on.
static const _advanced = 'Enable advanced: priority, status, view modes';
/// A row that opens a sub-screen: its glyph (muted = not recommended).
Widget _link(Icon icon, Future<void> Function() open) => ListTile(
contentPadding: EdgeInsets.zero,
leading: icon,
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(open()),
);
} }

49
lib/ui/status_cue.dart Normal file
View File

@ -0,0 +1,49 @@
/// A screen's latest result as a glyph in place of a status sentence.
library;
import 'package:flutter/material.dart';
/// One result: what to paint, whether it is a failure, and the old
/// sentence, kept as the spoken label only.
class StatusCue {
/// Creates a cue.
const StatusCue(this.icon, this.spoken, {this.error = false});
/// A success: a check glyph in the accent colour.
const StatusCue.ok(this.spoken) : icon = Icons.check_circle, error = false;
/// A failure: an error glyph in the danger hue.
const StatusCue.failed(this.spoken) : icon = Icons.error, error = true;
/// The glyph painted.
final IconData icon;
/// What a screen reader says.
final String spoken;
/// Whether this is a failure (danger hue) rather than a success.
final bool error;
}
/// Paints [cue] as a large glyph; colour is the second channel.
class StatusCueView extends StatelessWidget {
/// Creates the view.
const StatusCueView(this.cue, {super.key});
/// The result shown.
final StatusCue cue;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Align(
alignment: Alignment.centerLeft,
child: Icon(
cue.icon,
size: 32,
color: cue.error ? scheme.error : scheme.primary,
semanticLabel: cue.spoken,
),
);
}
}

View File

@ -0,0 +1,30 @@
/// Glyphs standing for note templates and their sections in the editor.
///
/// The section labels stay in `note_template.dart`: they are the note's own
/// `## heading` format, read by `parse()`, BACKLOG.md and the CLI. Only what
/// the editor paints around them is a glyph; the label and helper are spoken.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/note_template.dart';
/// The glyph for template [t]: a structured form, or a blank page.
IconData templateIcon(NoteTemplate t) =>
t.isFreeform ? Icons.note_outlined : Icons.assignment_outlined;
/// [t] as its glyph, spoken as its label.
Widget templateGlyph(NoteTemplate t) =>
Icon(templateIcon(t), semanticLabel: t.label);
/// The glyph heading a guided step, keyed by the section's stable `key`.
IconData sectionIcon(TemplateSection s) => switch (s.key) {
'title' => Icons.title,
'what' => Icons.lightbulb_outline,
'where' => Icons.place_outlined,
'must' => Icons.rule,
'done' => Icons.emoji_events_outlined,
'verify' => Icons.devices,
'refs' => Icons.menu_book_outlined,
_ => Icons.notes,
};

View File

@ -0,0 +1,55 @@
/// Framework strings the app paints without asking for them.
///
/// Material's own buttons (the app bar back arrow, a dialog's close, the
/// drawer, the overflow menu) carry tooltips from [MaterialLocalizations],
/// and a tooltip is painted on long-press and hover. kuhy's textgate rule
/// says nothing he sees has words he did not ask for, so these tooltips are
/// blanked: an empty tooltip is not built at all. Screen readers still get
/// the button's semantics from the icon itself.
library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// English Material strings with every button tooltip blanked.
class TextFreeMaterialLocalizations extends DefaultMaterialLocalizations {
/// Creates the localizations.
const TextFreeMaterialLocalizations();
@override
String get backButtonTooltip => '';
@override
String get closeButtonTooltip => '';
@override
String get openAppDrawerTooltip => '';
@override
String get showMenuTooltip => '';
@override
String get moreButtonTooltip => '';
@override
String get deleteButtonTooltip => '';
}
class _Delegate extends LocalizationsDelegate<MaterialLocalizations> {
const _Delegate();
@override
bool isSupported(Locale locale) => locale.languageCode == 'en';
@override
Future<MaterialLocalizations> load(Locale locale) =>
SynchronousFuture(const TextFreeMaterialLocalizations());
@override
bool shouldReload(_Delegate old) => false;
}
/// Pass as `MaterialApp.localizationsDelegates`.
const List<LocalizationsDelegate<dynamic>> kTextFreeLocalizations = [
_Delegate(),
];

View File

@ -10,6 +10,12 @@ The server is one more sync node: it reuses `lib/cli` (`CliSession`,
`todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`. `todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`.
Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`. Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`.
Output shapes: `list_tasks` and `search_tasks` print one tab-separated line per
task (`id8 status priority repo title created_by`); `get_task` prints
`key: value` lines (including `created_by`) then `---` and the text.
`created_by` is `claude` for notes filed through `add_task` or the CLI and
`kuhy` for everything else (a missing value reads as `kuhy`).
| What | Where | | What | Where |
|---|---| |---|---|
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` | | Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |

View File

@ -5,10 +5,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: android_file_picker name: android_file_picker
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8" sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.5"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@ -142,11 +142,11 @@ packages:
dependency: transitive dependency: transitive
description: description:
path: data_transfer_flutter path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.1" ref: "data_transfer_flutter-v0.2.2"
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
url: "https://github.com/kuhyx/utils" url: "https://github.com/kuhyx/utils"
source: git source: git
version: "0.2.1" version: "0.2.2"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@ -208,10 +208,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "13.3.2" version: "13.3.3"
file_picker_darwin: file_picker_darwin:
dependency: transitive dependency: transitive
description: description:
@ -224,10 +224,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_picker_linux name: file_picker_linux
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" version: "2.0.4"
file_picker_platform_interface: file_picker_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -240,10 +240,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_picker_web name: file_picker_web
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c sha256: "50749a6da2984fc29609e102ad4d80fd1d07e6895150f8e4922086167d9d45d8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "4.2.1"
file_selector: file_selector:
dependency: "direct main" dependency: "direct main"
description: description:
@ -399,11 +399,11 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: github_device_auth path: github_device_auth
ref: "github_device_auth-v0.3.2" ref: "github_device_auth-v0.4.0"
resolved-ref: "4ccaba1bb3e495ef72d70c6a7e7d7ec98d755792" resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff"
url: "https://github.com/kuhyx/utils" url: "https://github.com/kuhyx/utils"
source: git source: git
version: "0.3.2" version: "0.4.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@ -449,10 +449,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: idb_shim name: idb_shim
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b" sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.2" version: "2.11.3"
image: image:
dependency: "direct main" dependency: "direct main"
description: description:
@ -833,10 +833,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sembast name: sembast
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a" sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.10.0" version: "3.10.1"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -934,10 +934,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: sqflite_common_ffi name: sqflite_common_ffi
sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4" sha256: "9aad7a63bb8de6fe4c1e5367e8dcd8dfc89d491f44a526edf8e58a5a1fb1feed"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.5" version: "2.4.7"
sqflite_common_ffi_web: sqflite_common_ffi_web:
dependency: transitive dependency: transitive
description: description:
@ -1006,11 +1006,11 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: sync_settings_ui path: sync_settings_ui
ref: "sync_settings_ui-v0.6.2" ref: "sync_settings_ui-v0.7.0"
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488" resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff"
url: "https://github.com/kuhyx/utils" url: "https://github.com/kuhyx/utils"
source: git source: git
version: "0.6.2" version: "0.7.0"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@ -1159,10 +1159,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: windows_file_picker name: windows_file_picker
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "2.0.2"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.63+1 version: 1.0.83+1
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@ -47,7 +47,7 @@ dependencies:
url: https://github.com/kuhyx/utils url: https://github.com/kuhyx/utils
ref: design_system-v0.3.1 ref: design_system-v0.3.1
path: design_system path: design_system
file_picker: 13.3.2 file_picker: 13.3.3
file_selector: 1.1.0 file_selector: 1.1.0
flutter: flutter:
sdk: flutter sdk: flutter
@ -59,7 +59,7 @@ dependencies:
github_device_auth: github_device_auth:
git: git:
url: https://github.com/kuhyx/utils url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.3.2 ref: github_device_auth-v0.4.0
path: github_device_auth path: github_device_auth
# One-tap Google ID token via the framework AccountManager chooser (FOSS, # One-tap Google ID token via the framework AccountManager chooser (FOSS,
# no Play Services sign-in SDK). Used by the sync backend in lib/sync/. # no Play Services sign-in SDK). Used by the sync backend in lib/sync/.
@ -69,7 +69,7 @@ dependencies:
ref: google_account_token-v0.1.0 ref: google_account_token-v0.1.0
path: google_account_token path: google_account_token
http: 1.6.0 http: 1.6.0
idb_shim: 2.11.2 idb_shim: 2.11.3
# Re-encodes attached images (resize, EXIF strip) in pure Dart, so the # Re-encodes attached images (resize, EXIF strip) in pure Dart, so the
# phone and the desktop wrapper share one code path. # phone and the desktop wrapper share one code path.
image: 4.12.0 image: 4.12.0
@ -85,7 +85,7 @@ dependencies:
path_provider: 2.1.6 path_provider: 2.1.6
share_plus: 13.3.1 share_plus: 13.3.1
shared_preferences: 2.5.6 shared_preferences: 2.5.6
sqflite_common_ffi: 2.4.5 sqflite_common_ffi: 2.4.7
# Retained only to read the legacy on-device DB during the one-time # Retained only to read the legacy on-device DB during the one-time
# migration in NoteRepository.open; remove once all devices are migrated. # migration in NoteRepository.open; remove once all devices are migrated.
sqlite_crdt: 3.0.4 sqlite_crdt: 3.0.4
@ -97,7 +97,7 @@ dependencies:
sync_settings_ui: sync_settings_ui:
git: git:
url: https://github.com/kuhyx/utils url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.6.2 ref: sync_settings_ui-v0.7.0
path: sync_settings_ui path: sync_settings_ui
url_launcher: 6.3.3 url_launcher: 6.3.3
uuid: 4.6.0 uuid: 4.6.0

183
scripts/deploy.sh Executable file
View File

@ -0,0 +1,183 @@
#!/bin/bash
# ============================================================================
# Deploy todo everywhere it runs, then prove each one is live.
#
# scripts/deploy.sh # all targets: mcp desktop phone
# scripts/deploy.sh mcp desktop # only the named targets
#
# Targets (each has its own health check; any failure makes the exit non-zero,
# but the remaining targets still deploy so one unplugged phone does not leave
# the desktop on old code):
# mcp frozen binary + todo-mcp.service (mcp/install.sh), restart, then a
# real initialize + list_tasks against :8735 (mcp/verify_mcp.py).
# desktop web build + pacman package todo-flutter (install_arch.sh); health
# = installed package and bundled web version.json match pubspec.
# phone release APK via phone_deploy.sh (adb install -r only; it has no
# uninstall / pm clear path); health = versionName on the Pixel
# matches pubspec and the app process is running.
#
# Idempotent: desktop and phone skip the build when the installed version
# already equals pubspec's (every commit bumps the patch version, so an equal
# version means equal code); the MCP binary is cheap and always rebuilt.
#
# Nothing live runs from ~/src/todo (the MCP is a frozen binary, the desktop
# app lives in /opt/todo), so this deploys the checkout it sits in and never
# pulls kuhy's working tree.
# ============================================================================
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly ROOT
readonly SERIAL="${ADB_SERIAL:-23181JEGR08034}"
readonly APP_ID="dev.kuhy.todo"
readonly MCP_UNIT="todo-mcp.service"
readonly MCP_URL="http://127.0.0.1:8735/mcp"
readonly MCP_TOKEN="${HOME}/.config/todo-mcp-client/token"
readonly PHONE_DEPLOY="${HOME}/.claude/scripts/phone_deploy.sh"
readonly CAPPED="${HOME}/.claude/scripts/capped.sh"
readonly WEB_VERSION_JSON="/opt/todo/web/version.json"
readonly KEY_PROPS="$ROOT/android/key.properties"
readonly SHARED_KEY_PROPS="${HOME}/.android/release/key.properties"
# systemd units and Ship do not source ~/.zshenv; phone_deploy needs the SDK
# for its apksigner check.
export ANDROID_HOME="${ANDROID_HOME:-${HOME}/sdk/foss-android-sdk}"
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
FAILED=()
say() { echo "[deploy:$1] $2"; }
fail() {
echo "[deploy:$1] FAIL: $2" >&2
FAILED+=("$1")
}
# pubspec `version: 1.0.77+1` -> `1.0.77` (the +build part is CI's).
pubspec_version() {
sed -n 's/^version:[[:space:]]*\([^+[:space:]]*\).*/\1/p' "$ROOT/pubspec.yaml"
}
# Builds run under the shared resource cap when it exists on this machine.
heavy() {
if [[ -x "$CAPPED" ]]; then
CAP_MEM=4G CAP_CPU_PCT=20 "$CAPPED" "$@"
else
"$@"
fi
}
mcp_pid() { systemctl --user show -p MainPID --value "$MCP_UNIT" 2>/dev/null || echo 0; }
deploy_mcp() {
local old_pid new_pid out
old_pid="$(mcp_pid)"
heavy "$ROOT/mcp/install.sh" || { fail mcp "mcp/install.sh failed"; return; }
# install.sh only restarts a running unit; make sure it is enabled + up.
systemctl --user enable --now "$MCP_UNIT" >/dev/null 2>&1 ||
{ fail mcp "cannot start $MCP_UNIT"; return; }
systemctl --user is-active --quiet "$MCP_UNIT" ||
{ fail mcp "$MCP_UNIT not active"; return; }
new_pid="$(mcp_pid)"
if [[ "$new_pid" == 0 || "$new_pid" == "$old_pid" ]]; then
fail mcp "$MCP_UNIT was not restarted (pid $old_pid -> $new_pid)"
return
fi
[[ -s "$MCP_TOKEN" ]] || { fail mcp "no client token at $MCP_TOKEN"; return; }
out="$(python3 "$ROOT/mcp/verify_mcp.py" "$MCP_URL" \
--token-file "$MCP_TOKEN" --wait 20 2>&1)" ||
{ fail mcp "live check: $out"; return; }
say mcp "ok: pid $new_pid, ${out//$'\n'/, }"
}
desktop_versions_match() {
local want="$1" pkg web
pkg="$(pacman -Q todo-flutter 2>/dev/null | awk '{print $2}')"
web="$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' "$WEB_VERSION_JSON" 2>/dev/null)"
[[ "${pkg%-*}" == "$want" && "$web" == "$want" ]]
}
deploy_desktop() {
local want
want="$(pubspec_version)"
if desktop_versions_match "$want"; then
say desktop "ok: todo-flutter $want already installed"
return
fi
sudo -n true 2>/dev/null ||
{ fail desktop "sudo needs a password; run: ! $ROOT/install_arch.sh"; return; }
heavy "$ROOT/install_arch.sh" || { fail desktop "install_arch.sh failed"; return; }
if desktop_versions_match "$want"; then
say desktop "ok: todo-flutter $want installed"
else
fail desktop "installed version is not $want ($(pacman -Q todo-flutter 2>&1))"
fi
}
phone_version() {
adb -s "$SERIAL" shell dumpsys package "$APP_ID" 2>/dev/null |
sed -n 's/^[[:space:]]*versionName=\([^[:space:]]*\).*/\1/p' | head -n 1
}
phone_health() {
local want="$1" got
got="$(phone_version)"
[[ "$got" == "$want" ]] || { fail phone "versionName on phone is '$got', want $want"; return; }
adb -s "$SERIAL" shell pidof "$APP_ID" >/dev/null 2>&1 ||
adb -s "$SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
sleep 2
adb -s "$SERIAL" shell pidof "$APP_ID" >/dev/null 2>&1 ||
{ fail phone "$APP_ID is installed but not running"; return; }
say phone "ok: $APP_ID $want running on $SERIAL"
}
deploy_phone() {
local want rc=0
want="$(pubspec_version)"
if [[ "$(adb -s "$SERIAL" get-state 2>/dev/null)" != device ]]; then
fail phone "phone unreachable: $SERIAL not attached (plug it in, enable USB debugging)"
return
fi
if [[ "$(phone_version)" == "$want" ]]; then
say phone "already at $want, skipping build"
else
# A fresh checkout has no (gitignored) key.properties; without it the
# build signs with the debug key and cannot install over a CI build.
[[ -f "$KEY_PROPS" || ! -f "$SHARED_KEY_PROPS" ]] ||
install -m 600 "$SHARED_KEY_PROPS" "$KEY_PROPS"
# Outside a Claude session each phone_lease call would name its own
# pid as owner, so phone_deploy's launch step blocked on the lease its
# own install step took (exit 35). One owner for the whole run.
# Exit 48 = load too high right now; wait it out (up to 15 min).
local tries=0
while :; do
rc=0
PHONE_LEASE_OWNER="${PHONE_LEASE_OWNER:-deploy:$$}" ADB_SERIAL="$SERIAL" \
heavy "$PHONE_DEPLOY" "$ROOT" --release || rc=$?
((rc == 48 && ++tries <= 30)) || break
say phone "machine busy, retrying in 30 s ($tries/30)"
sleep 30
done
((rc == 0)) || { fail phone "phone_deploy.sh exited $rc (see its exit-code table)"; return; }
fi
phone_health "$want"
}
main() {
local targets=("$@") t
((${#targets[@]})) || targets=(mcp desktop phone)
for t in "${targets[@]}"; do
case "$t" in
mcp | desktop | phone) "deploy_$t" ;;
*) echo "usage: $0 [mcp] [desktop] [phone]" >&2; exit 2 ;;
esac
done
if ((${#FAILED[@]})); then
echo "[deploy] FAILED: ${FAILED[*]}" >&2
exit 1
fi
echo "[deploy] all healthy: ${targets[*]} at $(pubspec_version)"
}
main "$@"

View File

@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart'; import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart'; import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() { void main() {
testWidgets( testWidgets(
@ -140,7 +141,7 @@ void main() {
// Capture must never be interrupted by a snackbar… // Capture must never be interrupted by a snackbar…
expect(find.byType(SnackBar), findsNothing); expect(find.byType(SnackBar), findsNothing);
// …but the failure is no longer swallowed: the status line shows it. // …but the failure is no longer swallowed: the status line shows it.
expect(find.textContaining('Sync failed at'), findsOneWidget); expect(spoken('Sync failed at'), findsOneWidget);
expect(find.textContaining('offline'), findsOneWidget); expect(spoken('offline'), findsOneWidget);
}); });
} }

View File

@ -0,0 +1,21 @@
// A note typed into the app is stamped created_by kuhy.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'capture_screen_harness.dart';
void main() {
testWidgets('a captured note is stamped kuhy and keeps it on edit', (
tester,
) async {
final repo = await pumpCapture(tester);
await tester.enterText(find.byType(TextField).first, 'First line');
await tester.pump();
await tester.enterText(find.byType(TextField).first, 'First line, more');
await tester.pump();
final saved = (await repo.listNotes()).single;
expect(saved.text, 'First line, more');
expect(saved.createdBy, 'kuhy');
});
}

View File

@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart'; import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart'; import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() { void main() {
testWidgets('defaults to Raw, with Guided available via the entry wizard', ( testWidgets('defaults to Raw, with Guided available via the entry wizard', (
@ -29,18 +30,18 @@ void main() {
// Defaults to Raw: a single text field, no note persisted yet. // Defaults to Raw: a single text field, no note persisted yet.
expect(find.byType(TextField), findsOneWidget); expect(find.byType(TextField), findsOneWidget);
expect(find.text('Guided'), findsOneWidget); expect(find.bySemanticsLabel('Guided'), findsOneWidget);
expect(find.text('0'), findsOneWidget); expect(find.text('0'), findsOneWidget);
// Tapping Guided on the empty draft opens the priority+template wizard // Tapping Guided on the empty draft opens the priority+template wizard
// rather than jumping straight into the stepper. // rather than jumping straight into the stepper.
await tester.tap(find.text('Guided')); await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump(); await tester.pump();
expect(find.text('Step 1 of 2'), findsOneWidget); expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
await tester.tap(find.text('Next')); await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump(); await tester.pump();
await tester.tap(find.text('Start')); await tester.tap(find.bySemanticsLabel('Start'));
await tester.pump(); await tester.pump();
// Now on step 1 of the full-screen step page. // Now on step 1 of the full-screen step page.
@ -49,13 +50,13 @@ void main() {
find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'), find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'),
findsOneWidget, findsOneWidget,
); );
expect(find.textContaining('imperative'), findsOneWidget); // title helper expect(spoken('imperative'), findsOneWidget); // title helper
}); });
testWidgets('saving the untouched template creates no note', (tester) async { testWidgets('saving the untouched template creates no note', (tester) async {
final repo = await pumpCapture(tester); final repo = await pumpCapture(tester);
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(); await tester.pump();
expect(await repo.listNotes(), isEmpty); expect(await repo.listNotes(), isEmpty);
@ -83,7 +84,7 @@ void main() {
await tester.enterText(find.byType(TextField).first, 'A real idea'); await tester.enterText(find.byType(TextField).first, 'A real idea');
await tester.pump(); await tester.pump();
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(); await tester.pump();
expect(await repo.listNotes(), hasLength(1)); expect(await repo.listNotes(), hasLength(1));
@ -98,15 +99,16 @@ void main() {
) async { ) async {
await pumpCapture(tester); // empty prefs → no token → not configured await pumpCapture(tester); // empty prefs → no token → not configured
await tester.tap(find.byTooltip('Sync')); await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump(); // settings load + snackbar await tester.pump(); // settings load + snackbar
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
// The behaviour that matters is the routing, not the wording: an // The behaviour that matters is the routing, not the wording: an
// unconfigured device is sent to settings to connect a backend. Asserting // unconfigured device is sent to settings to connect a backend. Asserting
// on the snackbar text tied this to one phrasing, and the snackbar is // on the snackbar text tied this to one phrasing, and the snackbar is
// gone by the time settings has pushed anyway. // gone by the time settings has pushed anyway.
expect(find.textContaining('Connect Firebase'), findsWidgets); expect(spoken('Connect Firebase'), findsWidgets);
}); });
testWidgets('the notes-list button navigates to the list screen', ( testWidgets('the notes-list button navigates to the list screen', (
@ -114,11 +116,11 @@ void main() {
) async { ) async {
await pumpCapture(tester); await pumpCapture(tester);
await tester.tap(find.byTooltip('Notes')); await tester.tap(find.bySemanticsLabel('Notes'));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // route transition await tester.pump(const Duration(milliseconds: 300)); // route transition
expect(find.text('Notes'), findsOneWidget); // list screen app bar title expect(find.byIcon(Icons.note), findsOneWidget); // list screen title
}); });
testWidgets('changing the priority dropdown updates the saved note', ( testWidgets('changing the priority dropdown updates the saved note', (
@ -134,7 +136,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 400)); // menu open await tester.pump(const Duration(milliseconds: 400)); // menu open
await tester.tap(find.text('High').last); await tester.tap(find.bySemanticsLabel('High').last);
await tester.pump(); await tester.pump();
expect((await repo.listNotes()).single.priority, Priority.high); expect((await repo.listNotes()).single.priority, Priority.high);
@ -153,7 +155,7 @@ void main() {
); );
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 400)); // menu open await tester.pump(const Duration(milliseconds: 400)); // menu open
await tester.tap(find.text('In progress').last); await tester.tap(find.bySemanticsLabel('In progress').last);
await tester.pump(); await tester.pump();
expect((await repo.listNotes()).single.status, Status.inProgress); expect((await repo.listNotes()).single.status, Status.inProgress);

View File

@ -28,6 +28,7 @@ import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/capture_screen.dart'; import 'package:todo/ui/capture_screen.dart';
import 'package:todo/ui/github_mirror_screen.dart'; import 'package:todo/ui/github_mirror_screen.dart';
import 'package:todo/ui/settings_screen.dart'; import 'package:todo/ui/settings_screen.dart';
import 'package:todo/ui/text_free_localizations.dart';
import 'fake_note_repository.dart'; import 'fake_note_repository.dart';
import 'fake_secure_storage.dart'; import 'fake_secure_storage.dart';
@ -73,6 +74,8 @@ Future<FakeNoteRepository> pumpCapture(
debounce: Duration.zero, debounce: Duration.zero,
); );
final app = MaterialApp( final app = MaterialApp(
// As in main.dart: Material's own button tooltips are blanked.
localizationsDelegates: kTextFreeLocalizations,
home: CaptureScreen( home: CaptureScreen(
repository: repo, repository: repo,
appSettings: appSettings:

View File

@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart'; import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart'; import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() { void main() {
testWidgets( testWidgets(
@ -35,8 +36,8 @@ void main() {
await tester.pump(); await tester.pump();
// Only a failure is worth surfacing; a routine success is silent. // Only a failure is worth surfacing; a routine success is silent.
expect(find.textContaining('Synced at'), findsNothing); expect(spoken('Synced at'), findsNothing);
expect(find.textContaining('merged 0 device(s)'), findsNothing); expect(spoken('merged 0 device(s)'), findsNothing);
}, },
); );
@ -53,8 +54,8 @@ void main() {
); );
await tester.pump(); await tester.pump();
expect(find.textContaining('Sync failed at 12:00:00'), findsOneWidget); expect(spoken('Sync failed at 12:00:00'), findsOneWidget);
expect(find.textContaining('rate limited'), findsOneWidget); expect(spoken('rate limited'), findsOneWidget);
}); });
testWidgets('losing focus triggers a debounced auto-sync', (tester) async { testWidgets('losing focus triggers a debounced auto-sync', (tester) async {

View File

@ -20,6 +20,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart'; import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart'; import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() { void main() {
testWidgets('Sync with a configured token runs the sync service', ( testWidgets('Sync with a configured token runs the sync service', (
@ -35,18 +36,17 @@ void main() {
}); });
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock); await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
await tester.tap(find.byTooltip('Sync')); await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump(); // setState(_syncing = true) await tester.pump(); // setState(_syncing = true)
await tester.pump(); // service runs, snackbar scheduled await tester.pump(); // service runs, snackbar scheduled
await tester.pump(); // snackbar builds await tester.pump(); // snackbar builds
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
// No firebaseFactory was injected, so this runs over GitHub alone -- // No firebaseFactory was injected, so this runs over GitHub alone --
// the status line must say so rather than reading identically to a // the status line must say so rather than reading identically to a
// Firebase-connected sync. // Firebase-connected sync.
expect( expect(
find.textContaining( spoken('Synced: GitHub-only (Firebase not connected) — merged 0 device'),
'Synced: GitHub-only (Firebase not connected) — merged 0 device',
),
findsOneWidget, findsOneWidget,
); );
}); });
@ -55,14 +55,15 @@ void main() {
final mock = MockClient((_) async => throw Exception('offline')); final mock = MockClient((_) async => throw Exception('offline'));
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock); await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
await tester.tap(find.byTooltip('Sync')); await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
// 'Sync failed:' is the snackbar; the status line says 'Sync failed at'. // 'Sync failed:' is the snackbar; the status line says 'Sync failed at'.
expect(find.textContaining('Sync failed:'), findsOneWidget); expect(spoken('Sync failed:'), findsOneWidget);
expect(find.textContaining('Sync failed at'), findsOneWidget); expect(spoken('Sync failed at'), findsOneWidget);
}); });
testWidgets('manual sync reports peer files it could not decode', ( testWidgets('manual sync reports peer files it could not decode', (
@ -85,13 +86,14 @@ void main() {
}); });
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock); await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
await tester.tap(find.byTooltip('Sync')); await tester.tap(find.bySemanticsLabel('Sync'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect( expect(
find.textContaining('skipped 1 unreadable file(s)'), spoken('skipped 1 unreadable file(s)'),
findsWidgets, // snackbar and status line both call it out findsWidgets, // snackbar and status line both call it out
); );
}); });
@ -101,7 +103,7 @@ void main() {
) async { ) async {
await pumpCapture(tester); await pumpCapture(tester);
await tester.tap(find.byTooltip('Settings')); await tester.tap(find.bySemanticsLabel('Settings'));
await tester.pumpAndSettle(); // route transition await tester.pumpAndSettle(); // route transition
expect(find.byType(SettingsScreen), findsOneWidget); // settings is up expect(find.byType(SettingsScreen), findsOneWidget); // settings is up
@ -112,19 +114,19 @@ void main() {
// without popping a result at all, so a stale in-memory value would be // without popping a result at all, so a stale in-memory value would be
// wrong there too). This exercises that reload-after-return path, then // wrong there too). This exercises that reload-after-return path, then
// Back returns to capture. // Back returns to capture.
await tester.tap(find.text('Advanced sync (GitHub)')); await tester.tap(find.byIcon(Icons.cloud_sync));
await tester.pumpAndSettle(); // route transition await tester.pumpAndSettle(); // route transition
expect(find.byType(GitHubMirrorScreen), findsOneWidget); expect(find.byType(GitHubMirrorScreen), findsOneWidget);
await tester.tap(find.text('Save')); await tester.tap(find.bySemanticsLabel('Save'));
await tester.pumpAndSettle(); // save + pop transition back to Settings await tester.pumpAndSettle(); // save + pop transition back to Settings
expect(find.byType(SettingsScreen), findsOneWidget); expect(find.byType(SettingsScreen), findsOneWidget);
await tester.pageBack(); await tester.tap(find.byType(BackButton)); // no 'Back' tooltip to find
await tester.pumpAndSettle(); // pop transition back to capture await tester.pumpAndSettle(); // pop transition back to capture
expect(find.byType(SettingsScreen), findsNothing); // back on capture expect(find.byType(SettingsScreen), findsNothing); // back on capture
expect(find.byTooltip('Settings'), findsOneWidget); expect(find.bySemanticsLabel('Settings'), findsOneWidget);
}); });
// A MockClient that records request methods and answers the sync flow: // A MockClient that records request methods and answers the sync flow:
@ -148,42 +150,36 @@ void main() {
await tester.pump(); await tester.pump();
// The editor's own template/mode chrome is gone. // The editor's own template/mode chrome is gone.
expect(find.text('Guided'), findsNothing); expect(find.bySemanticsLabel('Guided'), findsNothing);
expect(find.text('Template'), findsNothing); expect(find.bySemanticsLabel('Template'), findsNothing);
// The capture screen's priority/status row is gone. // The capture screen's priority/status row is gone.
expect(find.text('Priority'), findsNothing); expect(find.bySemanticsLabel('Priority'), findsNothing);
expect(find.text('Status'), findsNothing); expect(find.bySemanticsLabel('Status'), findsNothing);
// Routine status chatter is gone; only a badge count remains. // Routine status chatter is gone; only a badge count remains.
expect(find.textContaining('Autosaves as you type'), findsNothing); expect(spoken('Autosaves as you type'), findsNothing);
expect(find.text('1'), findsOneWidget); expect(find.text('1'), findsOneWidget);
// The note still persisted normally underneath the simplified UI. // The note still persisted normally underneath the simplified UI.
expect(find.text('A plain idea'), findsOneWidget); expect(find.text('A plain idea'), findsOneWidget);
}, },
); );
testWidgets( testWidgets('draft text survives an advancedMode toggle mid-typing', (
'draft text survives an advancedMode toggle mid-typing', tester,
(tester) async { ) async {
final appSettings = ValueNotifier( final appSettings = ValueNotifier(const AppSettings(advancedMode: true));
const AppSettings(advancedMode: true), await pumpCapture(tester, appSettings: appSettings);
);
await pumpCapture(tester, appSettings: appSettings);
await tester.enterText( await tester.enterText(find.byType(TextField).first, 'my important idea');
find.byType(TextField).first, await tester.pump();
'my important idea',
);
await tester.pump();
// NoteForm's metadata row is conditionally present, so toggling // NoteForm's metadata row is conditionally present, so toggling
// advancedMode changes the editor's position beneath it — a key one // advancedMode changes the editor's position beneath it — a key one
// level too shallow doesn't survive that reposition and Flutter // level too shallow doesn't survive that reposition and Flutter
// remounts the editor with an empty draft, losing the typing. // remounts the editor with an empty draft, losing the typing.
appSettings.value = const AppSettings(advancedMode: false); appSettings.value = const AppSettings(advancedMode: false);
await tester.pump(); await tester.pump();
final field = tester.widget<TextField>(find.byType(TextField).first); final field = tester.widget<TextField>(find.byType(TextField).first);
expect(field.controller?.text, contains('my important idea')); expect(field.controller?.text, contains('my important idea'));
}, });
);
} }

View File

@ -0,0 +1,85 @@
// created_by through the CLI: stamping, output, and the backfill command.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'cli_harness.dart';
void main() {
late CliHarness h;
setUp(() => h = CliHarness());
tearDown(() => h.dispose());
test('a CLI add is stamped claude, repo task or idea', () async {
final idea = await h.add('Plain idea');
final task = await h.add('Fix gate', ['--repo', 'kuhyx/todo']);
for (final id in [idea, task]) {
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(h.out.toString(), contains('created_by: claude'));
}
expect(await h.run(['--list']), 0);
expect(
h.out.toString().trim().split('\n'),
everyElement(endsWith('\tclaude')),
);
});
test('a --new-note add is stamped claude', () async {
final id = await h.add('Own note', ['--repo', 'kuhyx/todo', '--new-note']);
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(h.out.toString(), contains('created_by: claude'));
});
test('a peer note without created_by reads as kuhy', () async {
h.seedPeer([fixtureNote('peer-note-1', 'From the phone')]);
expect(await h.run(['--get', 'peer-note-1']), 0);
expect(h.out.toString(), contains('created_by: kuhy'));
expect(await h.run(['--list']), 0);
expect(h.out.toString(), endsWith('From the phone\tkuhy\n'));
});
group('--backfill-created-by', () {
void seed() {
final mcp = Directory('${h.env['XDG_DATA_HOME']}/todo-mcp')
..createSync(recursive: true);
File('${mcp.path}/node_id').writeAsStringSync('mcp-node\n');
h.seedPeer([
fixtureNote('mcp-note-0001', 'Make a text-free', repo: 'kuhyx/a'),
], node: 'mcp-node');
h.seedPeer([fixtureNote('idea-note-0001', 'My idea')], node: 'phone');
}
test('--dry-run lists the plan and writes nothing', () async {
seed();
expect(await h.run(['--backfill-created-by', '--dry-run']), 0);
final lines = h.out.toString().trim().split('\n');
expect(lines, contains('mcp-note\tclaude\tMake a text-free'));
expect(lines, contains('idea-not\tkuhy\tMy idea'));
expect(lines.last, 'dry run: claude 1, kuhy 1');
expect(h.db.methods, isNot(contains('PUT')), reason: 'nothing pushed');
});
test('a real run sets it once, pushes it, and is then a no-op', () async {
seed();
expect(await h.run(['--backfill-created-by']), 0);
expect(h.out.toString().trim().split('\n').last, 'set: claude 1, kuhy 1');
expect(
await h.run(['--get', 'mcp-note-0001', '--data-dir', h.dir('b')]),
0,
);
expect(h.out.toString(), contains('created_by: claude'));
expect(
await h.run(['--get', 'idea-note-0001', '--data-dir', h.dir('b')]),
0,
);
expect(h.out.toString(), contains('created_by: kuhy'));
expect(await h.run(['--backfill-created-by']), 0);
expect(h.out.toString().trim(), 'set: claude 0, kuhy 0');
});
test('--dry-run is refused without the backfill flag', () async {
expect(await h.run(['--dry-run']), 2);
expect(h.err.toString(), contains('--dry-run only applies'));
});
});
}

View File

@ -32,7 +32,11 @@ class CliHarness {
String dir(String name) => '${tmp.path}/$name'; String dir(String name) => '${tmp.path}/$name';
Future<int> run(List<String> argv, {SessionOpener? opener}) async { Future<int> run(
List<String> argv, {
SessionOpener? opener,
String stdin = '',
}) async {
out.clear(); out.clear();
err.clear(); err.clear();
return runCli( return runCli(
@ -40,7 +44,7 @@ class CliHarness {
env: env, env: env,
out: out, out: out,
err: err, err: err,
readStdin: () async => '', readStdin: () async => stdin,
open: open:
opener ?? (d, {required seedCredentials}) => open(d, seedCredentials), opener ?? (d, {required seedCredentials}) => open(d, seedCredentials),
); );
@ -60,10 +64,10 @@ class CliHarness {
return out.toString().trim(); return out.toString().trim();
} }
/// Publishes [notes] as another device's log. /// Publishes [notes] as device [node]'s log.
void seedPeer(List<Note> notes) { void seedPeer(List<Note> notes, {String node = 'peer'}) {
db.files['todo-sync/notes/peer~2Ejson'] = logToJson({ db.files['todo-sync/notes/$node~2Ejson'] = logToJson({
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'), for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, node),
}); });
} }
} }

View File

@ -121,7 +121,7 @@ void main() {
expect(await run(['--list', '--data-dir', dir('b')]), 0); expect(await run(['--list', '--data-dir', dir('b')]), 0);
expect( expect(
out.toString().trim(), out.toString().trim(),
'${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle', '${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle\tclaude',
); );
expect(await run(['--get', id, '--data-dir', dir('b')]), 0); expect(await run(['--get', id, '--data-dir', dir('b')]), 0);

View File

@ -0,0 +1,83 @@
// --set-text: replace a note's whole text, keeping every other field.
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_args.dart';
import 'package:todo/cli/cli_mode.dart';
import 'package:todo/cli/cli_session.dart';
import 'cli_harness.dart';
import 'scripted_session.dart';
void main() {
late CliHarness h;
setUp(() => h = CliHarness());
tearDown(() => h.dispose());
test('--set-text replaces the text and keeps repo and status', () async {
final id = await h.add('Old title\nold body', ['--repo', 'kuhyx/todo']);
expect(await h.run(['--set-status', id, 'in_progress']), 0);
expect(
await h.run([
'--set-text',
id.substring(0, 8),
'--text',
'New title\nnew body',
'--data-dir',
h.dir('a'),
]),
0,
);
expect(h.out.toString().trim(), '$id\ttext');
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(
h.out.toString(),
allOf(
contains('New title\nnew body'),
isNot(contains('old body')),
contains('repo: kuhyx/todo'),
contains('in_progress'),
),
);
});
test('--set-text reads stdin when --text is absent', () async {
final id = await h.add('Old');
expect(await h.run(['--set-text', id], stdin: 'From stdin'), 0);
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(h.out.toString(), contains('From stdin'));
});
test('--set-text refuses empty text and changes nothing', () async {
final id = await h.add('Keep me');
expect(await h.run(['--set-text', id, '--text', ' ']), 2);
expect(h.err.toString(), contains('empty note'));
expect(await h.run(['--get', id]), 0);
expect(h.out.toString(), contains('Keep me'));
});
test('--set-text on a missing id exits 1', () async {
expect(await h.run(['--set-text', 'ffffffff', '--text', 'x']), 1);
expect(h.err.toString(), contains('no live note with id ffffffff'));
});
test('--set-text fails when the push reports nothing published', () async {
Future<CliSession> opener(String d, {required String seedCredentials}) =>
ScriptedSession.create(h.db.client, [true, false], noteId: 'stuck');
expect(
await h.run(['--set-text', 'stuck', '--text', 'x'], opener: opener),
1,
);
expect(h.err.toString(), contains('text change saved locally'));
});
test('parse: --set-text takes an id and allows --text', () {
final args = CliArgs.parse(['--set-text', 'abc', '--text', 'T']);
expect(args.mode, CliMode.setText);
expect(args.id, 'abc');
expect(args.text, 'T');
expect(() => CliArgs.parse(['--set-text']), throwsA(isA<UsageError>()));
expect(
() => CliArgs.parse(['--set-text', 'a', '--repo', 'kuhyx/todo']),
throwsA(isA<UsageError>()),
);
});
}

View File

@ -41,7 +41,7 @@ void main() {
expect(await h.run(['--list', '--repo', 'kuhyx/todo']), 0); expect(await h.run(['--list', '--repo', 'kuhyx/todo']), 0);
expect( expect(
h.out.toString(), h.out.toString(),
'${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\n', '${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\tclaude\n',
); );
expect(await h.run(['--list', '--status', 'done']), 0); expect(await h.run(['--list', '--status', 'done']), 0);
expect(h.out.toString(), isEmpty); expect(h.out.toString(), isEmpty);

View File

@ -65,12 +65,15 @@ void main() {
); );
}); });
test('listLine: 8-char id, status, priority, repo, title', () { test('listLine: 8-char id, status, priority, repo, title, author', () {
expect( expect(
listLine(notes[2].copyWith(status: Status.inProgress)), listLine(notes[2].copyWith(status: Status.inProgress)),
'12345678\tin_progress\tmedium\t-\tFirst', '12345678\tin_progress\tmedium\t-\tFirst\tkuhy',
);
expect(
listLine(notes[4]),
'99999999\ttodo\tmedium\tkuhyx/todo\tOnly\tkuhy',
); );
expect(listLine(notes[4]), '99999999\ttodo\tmedium\tkuhyx/todo\tOnly');
expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t')); expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t'));
}); });
} }

View File

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/ui/confirm_delete.dart';
/// Opens the dialog and returns a getter for its eventual answer.
Future<bool? Function()> _open(WidgetTester tester) async {
bool? answer;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () async => answer = await confirmDeleteNote(context),
child: const SizedBox.square(dimension: 40),
),
),
),
),
);
await tester.tap(find.byType(TextButton));
await tester.pumpAndSettle();
return () => answer;
}
void main() {
testWidgets('the delete glyph answers yes', (tester) async {
final answer = await _open(tester);
expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('confirm-delete')));
await tester.pumpAndSettle();
expect(answer(), isTrue);
});
testWidgets('cancel is autofocused, so Return keeps the note', (
tester,
) async {
final answer = await _open(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(answer(), isFalse);
});
testWidgets('Escape answers no', (tester) async {
final answer = await _open(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(answer(), isFalse);
});
testWidgets('tapping the barrier answers no', (tester) async {
final answer = await _open(tester);
await tester.tapAt(const Offset(5, 5));
await tester.pumpAndSettle();
expect(answer(), isFalse);
});
}

View File

@ -29,6 +29,11 @@ class FakeNoteRepository implements NoteRepository {
NoteSort? lastSort; NoteSort? lastSort;
NoteFilter? lastFilter; NoteFilter? lastFilter;
/// Calls to [upsert] and to the one-write [bulkUpdate], so a bulk action
/// can be shown to be one call rather than one write per note.
int upsertCalls = 0;
int bulkCalls = 0;
/// Emits the current snapshot to a new subscriber first (so late-binding /// Emits the current snapshot to a new subscriber first (so late-binding
/// [StreamBuilder]s get the seed), then forwards subsequent changes. /// [StreamBuilder]s get the seed), then forwards subsequent changes.
Stream<List<Note>> _snapshots() async* { Stream<List<Note>> _snapshots() async* {
@ -46,12 +51,37 @@ class FakeNoteRepository implements NoteRepository {
@override @override
Future<void> upsert(Note note) async { Future<void> upsert(Note note) async {
upsertCalls++;
_notes _notes
..removeWhere((n) => n.id == note.id) ..removeWhere((n) => n.id == note.id)
..add(note); ..add(note);
_emit(); _emit();
} }
@override
Future<void> bulkUpdate(
Set<String> ids, {
Priority? priority,
Status? status,
}) async {
bulkCalls++;
for (final (i, n) in _notes.indexed.toList()) {
if (ids.contains(n.id)) {
_notes[i] = n.copyWith(priority: priority, status: status);
}
}
_emit();
}
@override
Future<void> saveReplacing(Note note, Iterable<String> removed) async {
final gone = {...removed, note.id};
_notes
..removeWhere((n) => gone.contains(n.id))
..add(note);
_emit();
}
@override @override
Future<void> delete(String id) async { Future<void> delete(String id) async {
_notes.removeWhere((n) => n.id == id); _notes.removeWhere((n) => n.id == id);
@ -83,7 +113,9 @@ class FakeNoteRepository implements NoteRepository {
Future<List<Note>> listNotes({ Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc, NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(), NoteFilter filter = const NoteFilter(),
}) async => List.unmodifiable(_notes); }) async => List.unmodifiable(
applyFilter ? _notes.where((n) => matchesFilter(n, filter)) : _notes,
);
@override @override
Stream<List<Note>> watchNotes({ Stream<List<Note>> watchNotes({
@ -104,6 +136,12 @@ class FakeNoteRepository implements NoteRepository {
@override @override
Stream<int> watchCount() => _snapshots().map((n) => n.length); Stream<int> watchCount() => _snapshots().map((n) => n.length);
@override
Future<Map<String, String>> backfillCreatedBy(
Set<String> claudeNodes, {
bool dryRun = false,
}) async => {};
@override @override
String get nodeId => 'fake-node'; String get nodeId => 'fake-node';

View File

@ -18,6 +18,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart'; import 'fake_secure_storage.dart';
import 'github_mirror_harness.dart'; import 'github_mirror_harness.dart';
import 'text_free_guard.dart';
/// Stub launcher that records the URL instead of opening it, so `_openPage` /// Stub launcher that records the URL instead of opening it, so `_openPage`
/// can be exercised without a real platform channel. /// can be exercised without a real platform channel.
@ -72,7 +73,7 @@ void main() {
httpClient: mock, httpClient: mock,
); );
await tester.tap(find.text('Connect GitHub')); await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 50)); await tester.pump(const Duration(milliseconds: 50));
@ -80,7 +81,7 @@ void main() {
await tester.pump(); await tester.pump();
} }
expect(find.textContaining('sync failed'), findsOneWidget); expect(spoken('sync failed'), findsOneWidget);
}); });
testWidgets('Save persists the settings and closes the screen', ( testWidgets('Save persists the settings and closes the screen', (
@ -135,7 +136,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 400)); // route transition await tester.pump(const Duration(milliseconds: 400)); // route transition
expect(find.byType(GitHubMirrorScreen), findsOneWidget); // screen is up expect(find.byType(GitHubMirrorScreen), findsOneWidget); // screen is up
await tester.tap(find.text('Save')); await tester.tap(find.bySemanticsLabel('Save'));
await tester.pump(); // run _save (persist + pop) await tester.pump(); // run _save (persist + pop)
await tester.pump(const Duration(milliseconds: 400)); // pop transition await tester.pump(const Duration(milliseconds: 400)); // pop transition
@ -195,14 +196,15 @@ void main() {
httpClient: mock, httpClient: mock,
); );
await tester.tap(find.text('Connect GitHub')); await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump(); // requestDeviceCode await tester.pump(); // requestDeviceCode
await tester.pump(); // dialog builds, poll starts (interval 0) await tester.pump(); // dialog builds, poll starts (interval 0)
expect(find.text('WXYZ-1234'), findsOneWidget); expect(find.text('WXYZ-1234'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires
await tester.pump(); // token error throws → _error set await tester.pump(); // token error throws → _error set
expect(find.textContaining('nope'), findsOneWidget); // error rendered await tester.pump(const Duration(milliseconds: 300)); // dialog faded in
expect(spoken('nope'), findsOneWidget); // error rendered (spoken glyph)
// Tap the "open on GitHub" action: copies the code and launches the URL. // Tap the "open on GitHub" action: copies the code and launches the URL.
// _openPage awaits Clipboard.setData then the launcher's supportsMode + // _openPage awaits Clipboard.setData then the launcher's supportsMode +
@ -212,7 +214,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(launcher.launched, 'https://github.com/login/device'); expect(launcher.launched, 'https://github.com/login/device');
await tester.tap(find.text('Cancel')); await tester.tap(find.bySemanticsLabel('Cancel'));
await tester.pumpAndSettle(); // finish the dialog pop animation await tester.pumpAndSettle(); // finish the dialog pop animation
expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed
}); });

View File

@ -18,6 +18,7 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart'; import 'fake_secure_storage.dart';
import 'github_mirror_harness.dart'; import 'github_mirror_harness.dart';
import 'text_free_guard.dart';
/// Stub launcher that records the URL instead of opening it, so `_openPage` /// Stub launcher that records the URL instead of opening it, so `_openPage`
/// can be exercised without a real platform channel. /// can be exercised without a real platform channel.
@ -43,45 +44,42 @@ void main() {
tester, tester,
) async { ) async {
await pumpMirror(tester); await pumpMirror(tester);
await tester.tap(find.text('Connect GitHub')); await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump(); await tester.pump();
expect( expect(spoken('Enter the OAuth App client id'), findsOneWidget);
find.textContaining('Enter the OAuth App client id'),
findsOneWidget,
);
}); });
testWidgets('Test connection reports a reachable repo', (tester) async { testWidgets('Test connection reports a reachable repo', (tester) async {
final mock = MockClient((_) async => http.Response('{}', 200)); final mock = MockClient((_) async => http.Response('{}', 200));
await pumpMirror(tester, httpClient: mock); await pumpMirror(tester, httpClient: mock);
await tester.tap(find.text('Test connection')); await tester.tap(find.bySemanticsLabel('Test connection'));
await tester.pump(); // start await tester.pump(); // start
await tester.pump(); // resolve future + rebuild await tester.pump(); // resolve future + rebuild
expect(find.textContaining('reachable'), findsOneWidget); expect(spoken('reachable'), findsOneWidget);
}); });
testWidgets('Test connection reports an inaccessible repo', (tester) async { testWidgets('Test connection reports an inaccessible repo', (tester) async {
final mock = MockClient((_) async => http.Response('', 404)); final mock = MockClient((_) async => http.Response('', 404));
await pumpMirror(tester, httpClient: mock); await pumpMirror(tester, httpClient: mock);
await tester.tap(find.text('Test connection')); await tester.tap(find.bySemanticsLabel('Test connection'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
expect(find.textContaining('Could not access'), findsOneWidget); expect(spoken('Could not access'), findsOneWidget);
}); });
testWidgets('Test connection surfaces a network error', (tester) async { testWidgets('Test connection surfaces a network error', (tester) async {
final mock = MockClient((_) async => throw Exception('offline')); final mock = MockClient((_) async => throw Exception('offline'));
await pumpMirror(tester, httpClient: mock); await pumpMirror(tester, httpClient: mock);
await tester.tap(find.text('Test connection')); await tester.tap(find.bySemanticsLabel('Test connection'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
expect(find.textContaining('Error:'), findsOneWidget); expect(spoken('Error:'), findsOneWidget);
}); });
testWidgets('device flow failure to start shows a message', (tester) async { testWidgets('device flow failure to start shows a message', (tester) async {
@ -97,11 +95,11 @@ void main() {
httpClient: mock, httpClient: mock,
); );
await tester.tap(find.text('Connect GitHub')); await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
expect(find.textContaining('Could not start device flow'), findsOneWidget); expect(spoken('Could not start device flow'), findsOneWidget);
}); });
testWidgets('device flow happy path saves the token', (tester) async { testWidgets('device flow happy path saves the token', (tester) async {
@ -133,7 +131,7 @@ void main() {
httpClient: mock, httpClient: mock,
); );
await tester.tap(find.text('Connect GitHub')); await tester.tap(find.bySemanticsLabel('Connect GitHub'));
await tester.pump(); // requestDeviceCode await tester.pump(); // requestDeviceCode
await tester.pump(); // dialog builds, shows the user code await tester.pump(); // dialog builds, shows the user code
expect(find.text('WXYZ-1234'), findsOneWidget); expect(find.text('WXYZ-1234'), findsOneWidget);
@ -145,6 +143,6 @@ void main() {
await tester.pump(); await tester.pump();
} }
expect(find.textContaining('Connected and synced'), findsOneWidget); expect(spoken('Connected and synced'), findsOneWidget);
}); });
} }

83
test/log_watch_test.dart Normal file
View File

@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/log_watch.dart';
void main() {
late StreamController<void> changes;
late int computes;
setUp(() {
changes = StreamController<void>.broadcast(sync: true);
computes = 0;
});
tearDown(() => changes.close());
Stream<int> watch() => watchLog(changes.stream, () => ++computes);
test('the seed is emitted at once on listen', () async {
final seen = <int>[];
final sub = watch().listen(seen.add);
expect(computes, 1, reason: 'seeded inside listen, not on a timer');
await pumpEventQueue();
expect(seen, [1]);
await sub.cancel();
});
test(
'a burst of 5 changes yields one recompute carrying the latest',
() async {
final seen = <int>[];
final sub = watch().listen(seen.add);
for (var i = 0; i < 5; i++) {
changes.add(null);
}
expect(computes, 1, reason: 'nothing recomputed inside the burst');
await pumpEventQueue();
await sub.cancel();
expect(computes, 2);
expect(seen, [1, 2]);
},
);
test('changes after a flush start a new coalescing window', () async {
final seen = <int>[];
final sub = watch().listen(seen.add);
changes.add(null);
await pumpEventQueue();
changes.add(null);
changes.add(null);
await pumpEventQueue();
await sub.cancel();
expect(seen, [1, 2, 3]);
});
test(
'cancel drops a pending recompute and the change subscription',
() async {
final sub = watch().listen((_) {});
changes.add(null);
await sub.cancel();
await pumpEventQueue();
expect(computes, 1);
expect(changes.hasListener, isFalse);
},
);
test('each listener gets its own stream state', () async {
final a = <int>[];
final b = <int>[];
final subA = watch().listen(a.add);
final subB = watch().listen(b.add);
changes.add(null);
await pumpEventQueue();
await subA.cancel();
await subB.cancel();
expect(a.length, 2);
expect(b.length, 2);
});
}

View File

@ -32,13 +32,26 @@ class _Board {
String startBody = '{"ok":false,"error":{"message":"nope"}}'; String startBody = '{"ok":false,"error":{"message":"nope"}}';
String page = '<meta name="todoimpl-csrf" content="tok-1">'; String page = '<meta name="todoimpl-csrf" content="tok-1">';
BoardClient client() => BoardClient( /// Board reads so far; [onRead] may change [cards] between reads.
int reads = 0;
void Function(int read)? onRead;
Duration postDelay = Duration.zero;
BoardClient client({
Duration wait = Duration.zero,
Duration startTimeout = const Duration(seconds: 5),
}) => BoardClient(
wait: wait,
poll: const Duration(milliseconds: 5),
startTimeout: startTimeout,
client: MockClient((req) async { client: MockClient((req) async {
if (req.method == 'POST') { if (req.method == 'POST') {
posts.add(req); posts.add(req);
await Future<void>.delayed(postDelay);
return http.Response(startBody, startStatus); return http.Response(startBody, startStatus);
} }
if (req.url.path == '/api/board') { if (req.url.path == '/api/board') {
onRead?.call(++reads);
return http.Response.bytes( return http.Response.bytes(
utf8.encode(jsonEncode({'tasks': cards})), utf8.encode(jsonEncode({'tasks': cards})),
boardStatus, boardStatus,
@ -124,6 +137,78 @@ void main() {
); );
}); });
test('T1863: waits for the board to see a freshly merged section', () async {
final b = _Board([_card('cccccccc-0', 'Młody')]);
b.onRead = (n) {
if (n == 3) b.cards.add(_card('cccccccc-1', 'Młody'));
};
final r = await b
.client(wait: const Duration(seconds: 2))
.start(_id, 'Młody', sections: 2);
expect(r, (ok: true, reason: null, item: 'cccccccc-1'));
});
test('T1863: a board that never shows the section gives up', () async {
final b = _Board([_card('cccccccc-0', 'Młody')]);
final r = await b
.client(wait: const Duration(milliseconds: 50))
.start(_id, 'Młody', sections: 2);
expect(r.ok, isFalse);
expect(r.reason, 'no startable item');
expect(b.posts, isEmpty);
});
test('T1866: starts the merged item, not a re-startable older one', () async {
// The board lists by column: a cancelled older item may come last.
final b = _Board([
_card('cccccccc-0', 'Młody'),
_card('cccccccc-2', 'Młody'),
_card('cccccccc-1', 'Młody', column: 'done'),
]);
final r = await b.client().start(_id, 'Młody', sections: 3);
expect(r, (ok: true, reason: null, item: 'cccccccc-2'));
final other = _Board([
_card('cccccccc-1', 'Młody', column: 'done'),
_card('cccccccc-0', 'Młody', start: false),
]);
final none = await other.client().start(_id, 'Młody', sections: 1);
expect(none.reason, 'no startable item');
expect(other.posts, isEmpty);
});
test('T1864: a slow Start that took effect is ok, not unreachable', () async {
final b = _Board([_card('cccccccc-0', 'Młody')])
..postDelay = const Duration(milliseconds: 200);
b.onRead = (n) {
// After the POST was sent the card has left backlog (no Start action).
if (b.posts.isNotEmpty) {
b.cards[0] = _card(
'cccccccc-0',
'Młody',
column: 'running',
start: false,
);
}
};
final r = await b
.client(startTimeout: const Duration(milliseconds: 50))
.start(_id, 'Młody');
expect(r, (ok: true, reason: null, item: 'cccccccc-0'));
});
test(
'T1864: a slow Start that did not take effect is still a reason',
() async {
final b = _Board([_card('cccccccc-0', 'Młody')])
..postDelay = const Duration(milliseconds: 200);
final r = await b
.client(startTimeout: const Duration(milliseconds: 50))
.start(_id, 'Młody');
expect(r.ok, isFalse);
expect(r.reason, startsWith('board unreachable'));
},
);
test('the default client targets the loopback board', () { test('the default client targets the loopback board', () {
expect(BoardClient().base.toString(), 'http://127.0.0.1:8786'); expect(BoardClient().base.toString(), 'http://127.0.0.1:8786');
}); });

View File

@ -0,0 +1,108 @@
// T1865: the real board learns about notes by calling this MCP server, so a
// merged add_task must not hold the server while it waits for the board.
import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/mcp/board_client.dart';
import 'mcp_harness.dart';
void main() {
late McpHarness h;
late McpClient c;
late McpClient boardSide;
String? note;
var cards = <Map<String, dynamic>>[];
final posts = <String>[];
// A board whose every read refreshes its cards from this MCP server, as
// the real one does; a refresh that cannot get through keeps stale cards.
Future<List<Map<String, dynamic>>> refresh() async {
final id = note;
if (id == null) return cards;
try {
final text = await h
.call(boardSide, 'get_task', {'id': id})
.timeout(const Duration(milliseconds: 300));
final n = '\n$text'.split('\n## ').length - 1;
cards = [
for (var i = 0; i < n; i++)
{
'id': '${id.substring(0, 8)}-$i',
'title': 'Młody',
'column': 'backlog',
'actions': [
{'id': 'start', 'enabled': true},
],
},
];
} on TimeoutException {
// Stale, like the real cache when the server does not answer.
}
return cards;
}
final board = BoardClient(
wait: const Duration(seconds: 2),
poll: const Duration(milliseconds: 20),
client: MockClient((req) async {
if (req.method == 'POST') {
posts.add(req.url.path);
return http.Response('{"ok":true}', 200);
}
if (req.url.path == '/api/board') {
return http.Response.bytes(
utf8.encode(jsonEncode({'tasks': await refresh()})),
200,
);
}
return http.Response('<meta name="todoimpl-csrf" content="t">', 200);
}),
);
setUp(() async {
note = null;
cards = [];
posts.clear();
h = McpHarness(board: board);
await h.start();
c = await h.connect();
boardSide = await h.connect();
});
tearDown(() => h.dispose());
test('T1865: Start after a merge sees the board refreshed', () async {
final args = {
'repo': 'kuhyx/herosi',
'title': 'Młody',
'body': 'what',
'new_note': true,
'done_check': 'true',
};
note = idIn(await h.call(c, 'add_task', args));
final r = await h.call(c, 'add_task', args);
expect(r, startsWith('merged into $note'));
expect(r, contains('start: ok ${note!.substring(0, 8)}-1'));
expect(posts, ['/api/task/${note!.substring(0, 8)}-1/start']);
});
test('a merge with no board configured says click Start', () async {
final bare = McpHarness();
await bare.start();
addTearDown(bare.dispose);
final client = await bare.connect();
final args = {'repo': 'kuhyx/herosi', 'title': 'A', 'new_note': true};
final x = idIn(await bare.call(client, 'add_task', args));
final r = await bare.call(client, 'add_task', {
...args,
'done_check': 'true',
});
expect(r, startsWith('merged into $x'));
expect(r, contains('reason: "no board configured"'));
expect(r, contains('click Start on the board by hand'));
});
}

View File

@ -0,0 +1,88 @@
// add_task stamps created_by claude; every read tool reports it.
import 'package:flutter_test/flutter_test.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/data/note.dart';
import 'mcp_harness.dart';
void main() {
late McpHarness h;
late McpClient c;
setUp(() async {
h = McpHarness();
await h.start();
c = await h.connect();
});
tearDown(() => h.dispose());
test('add_task is stamped claude and list/get/search show it', () async {
final added = await h.call(c, 'add_task', {
'repo': 'kuhyx/todo',
'title': 'Fix gate',
});
final id = idIn(added);
final stored = (await h.session.repository.listNotes()).single;
expect(stored.createdBy, 'claude');
expect(
await h.call(c, 'get_task', {'id': id}),
contains('created_by: claude'),
);
for (final listed in [
await h.call(c, 'list_tasks', {}),
await h.call(c, 'search_tasks', {'query': 'gate'}),
]) {
expect(listed.trim(), endsWith('\tclaude'));
}
});
test('a separate new_note task is stamped claude too', () async {
await h.call(c, 'add_task', {
'repo': 'kuhyx/todo',
'title': 'Own note',
'new_note': true,
});
expect((await h.session.repository.listNotes()).single.createdBy, 'claude');
});
test('a record without created_by reads as kuhy', () async {
final at = DateTime.utc(2026, 11);
await h.session.repository.upsert(
Note(
id: 'human-task-0001',
text: 'Human task',
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: 'kuhyx/todo',
),
);
expect(
await h.call(c, 'get_task', {'id': 'human-task-0001'}),
contains('created_by: kuhy'),
);
expect(
(await h.call(c, 'list_tasks', {})).trim(),
endsWith('Human task\tkuhy'),
);
});
test('joining a human-made repo note keeps its author', () async {
final at = DateTime.utc(2026, 11);
await h.session.repository.upsert(
Note(
id: 'human-task-0001',
text: 'todo todos',
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: 'kuhyx/todo',
createdBy: 'kuhy',
),
);
await h.call(c, 'add_task', {'repo': 'kuhyx/todo', 'title': 'More'});
expect((await h.session.repository.listNotes()).single.createdBy, 'kuhy');
});
}

View File

@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart'; import 'package:http/testing.dart';
import 'package:mcp_dart/mcp_dart.dart'; import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/mcp/board_client.dart'; import 'package:todo/mcp/board_client.dart';
import 'package:todo/mcp/task_service.dart';
import 'mcp_harness.dart'; import 'mcp_harness.dart';
@ -15,9 +16,14 @@ void main() {
late McpHarness h; late McpHarness h;
late McpClient c; late McpClient c;
var cards = <Map<String, dynamic>>[]; var cards = <Map<String, dynamic>>[];
// Cards the board shows only from its next read on (its note cache lags).
var late_ = <Map<String, dynamic>>[];
var lag = 0; // board reads before [late_] shows
var up = true; var up = true;
final posts = <String>[]; final posts = <String>[];
final board = BoardClient( final board = BoardClient(
wait: const Duration(seconds: 2),
poll: const Duration(milliseconds: 5),
client: MockClient((req) async { client: MockClient((req) async {
if (!up) throw http.ClientException('connection refused'); if (!up) throw http.ClientException('connection refused');
if (req.method == 'POST') { if (req.method == 'POST') {
@ -25,17 +31,26 @@ void main() {
return http.Response('{"ok":true}', 200); return http.Response('{"ok":true}', 200);
} }
if (req.url.path == '/api/board') { if (req.url.path == '/api/board') {
if (lag-- == 0) {
cards = [...cards, ...late_];
late_ = [];
}
final shown = [...cards];
return http.Response.bytes( return http.Response.bytes(
utf8.encode(jsonEncode({'tasks': cards})), utf8.encode(jsonEncode({'tasks': shown})),
200, 200,
); );
} }
return http.Response('<meta name="todoimpl-csrf" content="t">', 200); return http.Response('<meta name="todoimpl-csrf" content="t">', 200);
}), }),
); );
List<Map<String, dynamic>> cardsOf(String id, String column) => [ List<Map<String, dynamic>> cardsOf(
String id,
String column, [
int index = 0,
]) => [
{ {
'id': '${id.substring(0, 8)}-0', 'id': '${id.substring(0, 8)}-$index',
'title': 'Młody', 'title': 'Młody',
'column': column, 'column': column,
'actions': [ 'actions': [
@ -46,6 +61,8 @@ void main() {
setUp(() async { setUp(() async {
cards = []; cards = [];
late_ = [];
lag = 0;
up = true; up = true;
posts.clear(); posts.clear();
h = McpHarness(board: board); h = McpHarness(board: board);
@ -78,16 +95,22 @@ void main() {
expect(posts, isEmpty); expect(posts, isEmpty);
}); });
test('C3/C6: a duplicate merges and Start is POSTed', () async { test(
final x = idIn(await add({'done_check': 'true'})); 'C3/C6/T1863: a duplicate merges, Start waits for the new item',
cards = cardsOf(x, 'backlog'); () async {
final r = await add({'done_check': 'true'}); final x = idIn(await add({'done_check': 'true'}));
expect(r, startsWith('merged into $x')); cards = cardsOf(x, 'backlog');
expect(r, contains('start: ok ${x.substring(0, 8)}-0')); // The merged section shows up one read later than the merge itself.
expect(posts, ['/api/task/${x.substring(0, 8)}-0/start']); late_ = cardsOf(x, 'backlog', 1);
final listed = await h.call(c, 'list_tasks', {'repo': _repo}); lag = 2; // past the guard's running read and the first Start read
expect(listed.trim().split('\n'), hasLength(1)); final r = await add({'done_check': 'true'});
}); expect(r, startsWith('merged into $x'));
expect(r, contains('start: ok ${x.substring(0, 8)}-1'));
expect(posts, ['/api/task/${x.substring(0, 8)}-1/start']);
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
expect(listed.trim().split('\n'), hasLength(1));
},
);
test('C6: done_check none or low priority merges without Start', () async { test('C6: done_check none or low priority merges without Start', () async {
final x = idIn(await add({})); final x = idIn(await add({}));
@ -120,6 +143,22 @@ void main() {
expect(r, contains('click Start on the board by hand')); expect(r, contains('click Start on the board by hand'));
}); });
test('a non-boolean new_note is refused by the service too', () {
// The MCP schema rejects it first; the service guards its own callers.
expect(
TaskService(
h.session,
).add({'repo': _repo, 'title': 'Młody', 'new_note': 'yes'}),
throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
'new_note: must be a boolean',
),
),
);
});
test('the add_task schema offers new_note', () async { test('the add_task schema offers new_note', () async {
final tools = (await c.listTools()).tools; final tools = (await c.listTools()).tools;
final add = tools.singleWhere((t) => t.name == 'add_task'); final add = tools.singleWhere((t) => t.name == 'add_task');

View File

@ -38,7 +38,7 @@ void main() {
await pumpCapture(tester); await pumpCapture(tester);
await _type(tester); await _type(tester);
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(); // one frame: the cue must not wait for anything await tester.pump(); // one frame: the cue must not wait for anything
expect(haptics, [_lightImpact]); expect(haptics, [_lightImpact]);
@ -52,7 +52,7 @@ void main() {
await pumpCapture(tester); await pumpCapture(tester);
await _type(tester); await _type(tester);
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(_mark, findsOneWidget); expect(_mark, findsOneWidget);
@ -74,7 +74,7 @@ void main() {
final haptics = recordHaptics(tester); final haptics = recordHaptics(tester);
await pumpCapture(tester); await pumpCapture(tester);
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(haptics, isEmpty); expect(haptics, isEmpty);
@ -87,16 +87,18 @@ void main() {
final haptics = recordHaptics(tester); final haptics = recordHaptics(tester);
await pumpCapture(tester); await pumpCapture(tester);
await _type(tester); await _type(tester);
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(); // the ticker's clock starts on its first frame await tester.pump(); // the ticker's clock starts on its first frame
await tester.pump(const Duration(milliseconds: 700)); await tester.pump(const Duration(milliseconds: 700));
await tester.tap(find.byTooltip('New note')); // nothing new to capture await tester.tap(
find.bySemanticsLabel('New note'),
); // nothing new to capture
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(haptics, hasLength(1)); expect(haptics, hasLength(1));
expect(_mark, findsNothing); expect(_mark, findsNothing);
await _type(tester); await _type(tester);
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(haptics, hasLength(2)); expect(haptics, hasLength(2));
expect(_mark, findsOneWidget); expect(_mark, findsOneWidget);
@ -111,7 +113,7 @@ void main() {
await pumpCapture(tester); await pumpCapture(tester);
await _type(tester); await _type(tester);
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(); await tester.pump();
expect(haptics, [_lightImpact]); expect(haptics, [_lightImpact]);
@ -130,7 +132,7 @@ void main() {
await pumpCapture(tester); await pumpCapture(tester);
final before = tester.getTopLeft(find.text('0')); final before = tester.getTopLeft(find.text('0'));
await _type(tester); await _type(tester);
await tester.tap(find.byTooltip('New note')); await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300));
expect(tester.getTopLeft(find.text('1')), before); expect(tester.getTopLeft(find.text('1')), before);
await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400));

View File

@ -13,7 +13,7 @@ double _barValue(WidgetTester tester) => tester
.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator)) .widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
.value!; .value!;
Future<void> _pushPage(WidgetTester tester) async { Future<Route<void>> _pushPage(WidgetTester tester) async {
late BuildContext ctx; late BuildContext ctx;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@ -25,12 +25,10 @@ Future<void> _pushPage(WidgetTester tester) async {
), ),
), ),
); );
unawaited( final route = appRoute<void>(ctx, (_) => const Scaffold(body: Text('page')));
Navigator.of( unawaited(Navigator.of(ctx).push<void>(route));
ctx,
).push<void>(appRoute(ctx, (_) => const Scaffold(body: Text('page')))),
);
await tester.pump(); await tester.pump();
return route;
} }
void main() { void main() {
@ -46,22 +44,20 @@ void main() {
expect(offenders, isEmpty); expect(offenders, isEmpty);
}); });
testWidgets('a pushed page eases in over AppDuration.slow', (tester) async { testWidgets('a pushed page is instant: no fade or slide', (tester) async {
await _pushPage(tester); final route = await _pushPage(tester) as PageRoute<void>;
await tester.pump(AppDuration.slow ~/ 4); expect(route.transitionDuration, Duration.zero);
expect(route.reverseTransitionDuration, Duration.zero);
// Fully visible and covering the old page after a single pump.
expect(find.text('home'), findsNothing);
expect(find.text('page'), findsOneWidget); expect(find.text('page'), findsOneWidget);
final fade = tester.widget<FadeTransition>( final page = find.ancestor(
find of: find.text('page'),
.ancestor( matching: find.byType(FadeTransition),
of: find.text('page'),
matching: find.byType(FadeTransition),
)
.first,
); );
expect(fade.opacity.value, inExclusiveRange(0, 1)); for (final f in tester.widgetList<FadeTransition>(page)) {
expect(f.opacity.value, 1);
await tester.pump(AppDuration.slow); }
expect(find.text('home'), findsNothing); // fully covered, route settled
}); });
testWidgets('remove-animations: the page is there at once', (tester) async { testWidgets('remove-animations: the page is there at once', (tester) async {
@ -77,7 +73,7 @@ void main() {
final total = spec.sections.length; final total = spec.sections.length;
expect(_barValue(tester), closeTo(1 / total, 1e-9)); expect(_barValue(tester), closeTo(1 / total, 1e-9));
await tester.tap(find.text('Next')); await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump(); await tester.pump();
await tester.pump(AppDuration.base ~/ 2); await tester.pump(AppDuration.base ~/ 2);
expect(_barValue(tester), inExclusiveRange(1 / total, 2 / total)); expect(_barValue(tester), inExclusiveRange(1 / total, 2 / total));
@ -89,7 +85,7 @@ void main() {
testWidgets('remove-animations: the progress bar jumps', (tester) async { testWidgets('remove-animations: the progress bar jumps', (tester) async {
disableAnimations(tester); disableAnimations(tester);
await pumpEditor(tester, initialTemplate: spec); await pumpEditor(tester, initialTemplate: spec);
await tester.tap(find.text('Next')); await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump(); await tester.pump();
expect(_barValue(tester), closeTo(2 / spec.sections.length, 1e-9)); expect(_barValue(tester), closeTo(2 / spec.sections.length, 1e-9));
}); });

View File

@ -0,0 +1,181 @@
// created_by: record mapping, merge safety against older builds, list order,
// the hide filter and backfill classification.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/created_by_backfill.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
Note _note(
String id, {
String? createdBy,
Priority priority = Priority.medium,
DateTime? updated,
}) {
final at = updated ?? DateTime.utc(2026, 11);
return Note(
id: id,
text: 'note $id',
priority: priority,
status: Status.todo,
createdAt: at,
updatedAt: at,
createdBy: createdBy,
);
}
/// What an older build writes: no created_by, replacing the record locally.
Map<String, Field> _oldFields(Note n, Hlc hlc) => {
'text': (n.text, hlc),
'priority': (n.priority.value, hlc),
'status': (n.status.value, hlc),
'created_at': (n.createdAt.toIso8601String(), hlc),
'updated_at': (n.updatedAt.toIso8601String(), hlc),
};
Log _overTheWire(Log log) => logFromJson(logToJson(log));
void main() {
group('record mapping', () {
test('created_by is written only when set and read back', () {
final hlc = Hlc(wallTimeMs: 1, counter: 0, nodeId: 'n');
expect(
noteFields(_note('a'), hlc).containsKey('created_by'),
isFalse,
reason: 'an unset field keeps the record wire-identical',
);
final fields = noteFields(_note('a', createdBy: 'claude'), hlc);
expect(fields['created_by']!.$1, 'claude');
final back = noteFromRecord(Record(id: 'a', fields: fields));
expect(back.createdBy, 'claude');
expect(back.isClaudeMade, isTrue);
expect(_note('b', createdBy: 'kuhy').isClaudeMade, isFalse);
expect(_note('c').isClaudeMade, isFalse);
});
test('copyWith keeps createdBy unless replaced', () {
final n = _note('a', createdBy: 'claude');
expect(n.copyWith(text: 'x').createdBy, 'claude');
expect(n.copyWith(createdBy: 'kuhy').createdBy, 'kuhy');
});
});
group('merge safety', () {
test(
'an older build upsert (no created_by) does not wipe a peer value',
() async {
final current = await NoteRepository.openInMemory(nodeId: 'new');
await current.upsert(_note('a', createdBy: 'claude'));
final old = LogStore(persistence: MemoryPersistence(), nodeId: 'old');
await old.load();
await old.replaceAll(
mergeLogs(old.snapshot(), _overTheWire(current.exportLog())),
);
final edited = _note('a', updated: DateTime.utc(2026, 11, 2));
await old.upsert(
Record(id: 'a', fields: _oldFields(edited, old.nextHlc())),
);
expect(old.snapshot()['a']!.fields.containsKey('created_by'), isFalse);
await current.importLog(_overTheWire(old.snapshot()));
expect((await current.listNotes()).single.createdBy, 'claude');
// Pulling the other way restores it on the older device too.
await old.replaceAll(
mergeLogs(old.snapshot(), _overTheWire(current.exportLog())),
);
expect(old.snapshot()['a']!.fields['created_by']!.$1, 'claude');
},
);
test('upserting a note without createdBy keeps the stored value', () async {
final repo = await NoteRepository.openInMemory();
await repo.upsert(_note('a', createdBy: 'claude'));
await repo.upsert(_note('a', updated: DateTime.utc(2026, 11, 3)));
expect((await repo.listNotes()).single.createdBy, 'claude');
await repo.importNotes([_note('a', updated: DateTime.utc(2027))]);
expect((await repo.listNotes()).single.createdBy, 'claude');
});
});
group('order and hiding', () {
final claudeHigh = _note('c', createdBy: 'claude', priority: Priority.high);
final humanLow = _note('h', createdBy: 'kuhy', priority: Priority.low);
final legacy = _note('l', priority: Priority.medium);
test(
'human notes come first under every sort, then the old order',
() async {
final repo = await NoteRepository.openInMemory();
for (final n in [claudeHigh, humanLow, legacy]) {
await repo.upsert(n);
}
for (final sort in NoteSort.values) {
final ids = [for (final n in await repo.listNotes(sort: sort)) n.id];
expect(ids.last, 'c', reason: '$sort');
}
final byPriority = await repo.listNotes();
expect([for (final n in byPriority) n.id], ['l', 'h', 'c']);
},
);
test('hideClaude drops Claude-made notes and is not a user facet', () {
const filter = NoteFilter(hideClaude: true);
expect(matchesFilter(claudeHigh, filter), isFalse);
expect(matchesFilter(humanLow, filter), isTrue);
expect(matchesFilter(legacy, filter), isTrue);
expect(matchesFilter(claudeHigh, const NoteFilter()), isTrue);
expect(filter.isEmpty, isTrue);
expect(filter.activeCount, 0);
expect(filter.copyWith(query: 'x').hideClaude, isTrue);
expect(filter.copyWith(hideClaude: false).hideClaude, isFalse);
});
});
group('backfill', () {
Record rec(String id, String node, {String? createdBy, bool dead = false}) {
final hlc = Hlc(wallTimeMs: 5, counter: 0, nodeId: node);
return Record(
id: id,
fields: {...noteFields(_note(id, createdBy: createdBy), hlc)},
deleted: dead,
deletedHlc: dead ? hlc : null,
);
}
test('classifies by the node that wrote created_at', () {
const claude = {'mcp', 'cli'};
expect(classifyCreatedBy(rec('a', 'mcp'), claude), 'claude');
expect(classifyCreatedBy(rec('a', 'cli'), claude), 'claude');
expect(classifyCreatedBy(rec('a', 'phone'), claude), 'kuhy');
const bare = Record(id: 'x', fields: {});
expect(classifyCreatedBy(bare, claude), 'kuhy');
});
test('writes only unset, live records, and only that field', () async {
final repo = await NoteRepository.openInMemory(nodeId: 'cli');
await repo.importLog({
'a': rec('a', 'mcp'),
'b': rec('b', 'phone'),
'c': rec('c', 'phone', createdBy: 'claude'),
'd': rec('d', 'mcp', dead: true),
});
const claude = {'mcp', 'cli'};
final dry = await repo.backfillCreatedBy(claude, dryRun: true);
expect(dry, {'a': 'claude', 'b': 'kuhy'});
expect(repo.exportLog()['a']!.fields.containsKey('created_by'), isFalse);
final done = await repo.backfillCreatedBy(claude);
expect(done, dry);
final log = repo.exportLog();
expect(log['a']!.fields['created_by']!.$1, 'claude');
expect(log['b']!.fields['created_by']!.$1, 'kuhy');
expect(log['c']!.fields['created_by']!.$1, 'claude');
expect(log['d']!.fields.containsKey('created_by'), isFalse);
// created_at keeps its original clock: the signal is not destroyed.
expect(log['b']!.fields['created_at']!.$2.nodeId, 'phone');
expect(await repo.backfillCreatedBy(claude), isEmpty);
});
});
}

View File

@ -61,7 +61,7 @@ void main() {
find.byWidgetPredicate((w) => w is DropdownButton<Priority>), find.byWidgetPredicate((w) => w is DropdownButton<Priority>),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('High').last); await tester.tap(find.bySemanticsLabel('High').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect((await repo.listNotes()).single.priority, Priority.high); expect((await repo.listNotes()).single.priority, Priority.high);
@ -74,7 +74,7 @@ void main() {
find.byWidgetPredicate((w) => w is DropdownButton<Status>), find.byWidgetPredicate((w) => w is DropdownButton<Status>),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Done').last); await tester.tap(find.bySemanticsLabel('Done').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect((await repo.listNotes()).single.status, Status.done); expect((await repo.listNotes()).single.status, Status.done);
@ -85,7 +85,7 @@ void main() {
) async { ) async {
final repo = await pumpDetail(tester, seedNote('# T\n\n## what\nold')); final repo = await pumpDetail(tester, seedNote('# T\n\n## what\nold'));
await tester.tap(find.text('Raw')); await tester.tap(find.bySemanticsLabel('Raw'));
await tester.pump(); await tester.pump();
await tester.enterText(find.byType(TextField), '# T\n\n## what\nnew body'); await tester.enterText(find.byType(TextField), '# T\n\n## what\nnew body');
await tester.pump(); await tester.pump();
@ -101,20 +101,20 @@ void main() {
await tester.enterText(find.byType(TextField), ''); await tester.enterText(find.byType(TextField), '');
await tester.pump(); await tester.pump();
await tester.tap(find.text('Guided')); await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump(); await tester.pump();
expect(find.text('Step 1 of 2'), findsOneWidget); expect(find.bySemanticsLabel('Step 1 of 2'), findsOneWidget);
// Wizard hides the Status dropdown (onChromeVisibleChanged(false)). // Wizard hides the Status dropdown (onChromeVisibleChanged(false)).
expect(find.text('Status'), findsNothing); expect(find.bySemanticsLabel('Status'), findsNothing);
await tester.tap(find.byType(DropdownButtonFormField<Priority>)); await tester.tap(find.byType(DropdownButtonFormField<Priority>));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('High').last); await tester.tap(find.bySemanticsLabel('High').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Next')); await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump(); await tester.pump();
await tester.tap(find.text('Start')); await tester.tap(find.bySemanticsLabel('Start'));
await tester.pump(); await tester.pump();
expect((await repo.listNotes()).single.priority, Priority.high); expect((await repo.listNotes()).single.priority, Priority.high);
@ -134,11 +134,11 @@ void main() {
advancedMode: false, advancedMode: false,
); );
expect(find.text('Template'), findsNothing); expect(find.bySemanticsLabel('Template'), findsNothing);
expect(find.text('Guided'), findsNothing); expect(find.bySemanticsLabel('Guided'), findsNothing);
expect(find.text('Raw'), findsNothing); expect(find.bySemanticsLabel('Raw'), findsNothing);
expect(find.text('Priority'), findsNothing); expect(find.bySemanticsLabel('Priority'), findsNothing);
expect(find.text('Status'), findsNothing); expect(find.bySemanticsLabel('Status'), findsNothing);
// The body itself is still editable — only the chrome is gone. // The body itself is still editable — only the chrome is gone.
expect(find.byType(TextField), findsOneWidget); expect(find.byType(TextField), findsOneWidget);
}); });
@ -146,13 +146,13 @@ void main() {
testWidgets('the delete action removes the note and pops', (tester) async { testWidgets('the delete action removes the note and pops', (tester) async {
final repo = await pumpDetail(tester, seedNote('# Bye')); final repo = await pumpDetail(tester, seedNote('# Bye'));
await tester.tap(find.byTooltip('Delete note')); await tester.tap(find.bySemanticsLabel('Delete note'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Delete is now confirmed. Deleting a note is irreversible and there is no // Delete is now confirmed. Deleting a note is irreversible and there is no
// undo, so a single Return on a focused control must not destroy it. // undo, so a single Return on a focused control must not destroy it.
expect(find.text('Delete note?'), findsOneWidget); expect(find.bySemanticsLabel('Delete note?'), findsOneWidget);
await tester.tap(find.text('Delete')); await tester.tap(find.byKey(const ValueKey('confirm-delete')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(await repo.listNotes(), isEmpty); expect(await repo.listNotes(), isEmpty);
@ -162,9 +162,9 @@ void main() {
testWidgets('cancelling the delete keeps the note', (tester) async { testWidgets('cancelling the delete keeps the note', (tester) async {
final repo = await pumpDetail(tester, seedNote('# Keep me')); final repo = await pumpDetail(tester, seedNote('# Keep me'));
await tester.tap(find.byTooltip('Delete note')); await tester.tap(find.bySemanticsLabel('Delete note'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Cancel')); await tester.tap(find.byKey(const ValueKey('confirm-cancel')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(await repo.listNotes(), hasLength(1)); expect(await repo.listNotes(), hasLength(1));

View File

@ -4,8 +4,10 @@ import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart'; import 'package:todo/data/note_template.dart';
import 'package:todo/ui/markdown_view.dart'; import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor.dart'; import 'package:todo/ui/note_editor.dart';
import 'package:todo/ui/template_glyphs.dart';
import 'note_editor_harness.dart'; import 'note_editor_harness.dart';
import 'text_free_guard.dart';
void main() { void main() {
testWidgets('guided: typing the title emits an assembled # heading', ( testWidgets('guided: typing the title emits an assembled # heading', (
@ -13,7 +15,7 @@ void main() {
) async { ) async {
final emitted = await pumpEditor(tester, initialTemplate: spec); final emitted = await pumpEditor(tester, initialTemplate: spec);
expect(find.byTooltip('Exit guided'), findsOneWidget); expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, 'Dark mode'); await tester.enterText(find.byType(TextField).first, 'Dark mode');
await tester.pump(); await tester.pump();
@ -28,11 +30,11 @@ void main() {
expect(find.byType(LinearProgressIndicator), findsOneWidget); expect(find.byType(LinearProgressIndicator), findsOneWidget);
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget); expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
await tester.tap(find.text('Next')); await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump(); await tester.pump();
expect(find.text('2 / ${spec.sections.length}'), findsOneWidget); expect(find.text('2 / ${spec.sections.length}'), findsOneWidget);
await tester.tap(find.text('Back')); await tester.tap(find.bySemanticsLabel('Back'));
await tester.pump(); await tester.pump();
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget); expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
expect(find.byType(LinearProgressIndicator), findsOneWidget); expect(find.byType(LinearProgressIndicator), findsOneWidget);
@ -49,21 +51,22 @@ void main() {
for (var i = 0; i < spec.sections.length; i++) { for (var i = 0; i < spec.sections.length; i++) {
final section = spec.sections[i]; final section = spec.sections[i];
expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget); expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget);
// Each step paints its section glyph; label + helper are spoken.
expect( expect(
find.text(section.isTitle ? 'title' : section.label), find.byIcon(sectionIcon(section)),
findsWidgets, findsOneWidget,
reason: 'step ${i + 1} should show "${section.label}"', reason: 'step ${i + 1} should show "${section.label}"',
); );
expect(find.text(section.helper), findsOneWidget); expect(spoken(section.helper), findsOneWidget);
if (i < spec.sections.length - 1) { if (i < spec.sections.length - 1) {
await tester.tap(find.text('Next')); await tester.tap(find.bySemanticsLabel('Next'));
await tester.pump(); await tester.pump();
} }
} }
// The last step is `read first`; there is no Next past it. // The last step is `read first`; there is no Next past it.
expect(spec.sections.last.label, 'read first'); expect(spec.sections.last.label, 'read first');
expect(find.text('Next'), findsNothing); expect(find.bySemanticsLabel('Next'), findsNothing);
}); });
testWidgets('pasting a bare link drops the draft to the blank template', ( testWidgets('pasting a bare link drops the draft to the blank template', (
@ -74,7 +77,7 @@ void main() {
initialTemplate: spec, initialTemplate: spec,
initialMode: NoteEditorMode.raw, initialMode: NoteEditorMode.raw,
); );
expect(find.text('LLM design spec'), findsOneWidget); expect(find.byIcon(Icons.assignment_outlined), findsOneWidget);
await tester.enterText( await tester.enterText(
find.byType(TextField).first, find.byType(TextField).first,
@ -83,8 +86,8 @@ void main() {
await tester.pump(); await tester.pump();
// Freeform now, so there is no seven-step stepper to walk for a link. // Freeform now, so there is no seven-step stepper to walk for a link.
expect(find.text('Blank'), findsOneWidget); expect(find.byIcon(Icons.note_outlined), findsOneWidget);
expect(find.text('Guided'), findsNothing); expect(find.bySemanticsLabel('Guided'), findsNothing);
// Adding prose puts the spec template back — lossless in both directions. // Adding prose puts the spec template back — lossless in both directions.
await tester.enterText( await tester.enterText(
@ -92,8 +95,8 @@ void main() {
'https://example.com/x — port this to Dart', 'https://example.com/x — port this to Dart',
); );
await tester.pump(); await tester.pump();
expect(find.text('LLM design spec'), findsOneWidget); expect(find.byIcon(Icons.assignment_outlined), findsOneWidget);
expect(find.text('Guided'), findsOneWidget); expect(find.bySemanticsLabel('Guided'), findsOneWidget);
}); });
testWidgets('an explicit template choice survives further typing', ( testWidgets('an explicit template choice survives further typing', (
@ -105,16 +108,16 @@ void main() {
initialMode: NoteEditorMode.raw, initialMode: NoteEditorMode.raw,
); );
await tester.tap(find.text('LLM design spec')); await tester.tap(find.byIcon(Icons.assignment_outlined));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Blank').last); await tester.tap(find.byIcon(Icons.note_outlined).last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Blank'), findsOneWidget); expect(find.byIcon(Icons.note_outlined), findsOneWidget);
// Prose would normally re-select the spec template; an explicit pick wins. // Prose would normally re-select the spec template; an explicit pick wins.
await tester.enterText(find.byType(TextField).first, 'build a thing'); await tester.enterText(find.byType(TextField).first, 'build a thing');
await tester.pump(); await tester.pump();
expect(find.text('Blank'), findsOneWidget); expect(find.byIcon(Icons.note_outlined), findsOneWidget);
}); });
testWidgets('switching to View renders the note as Markdown', (tester) async { testWidgets('switching to View renders the note as Markdown', (tester) async {
@ -126,7 +129,7 @@ void main() {
await tester.enterText(find.byType(TextField).first, '# Render me'); await tester.enterText(find.byType(TextField).first, '# Render me');
await tester.pump(); await tester.pump();
await tester.tap(find.text('View')); await tester.tap(find.bySemanticsLabel('View'));
await tester.pump(); await tester.pump();
expect(find.byType(MarkdownView), findsOneWidget); expect(find.byType(MarkdownView), findsOneWidget);

View File

@ -6,6 +6,7 @@ import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor.dart'; import 'package:todo/ui/note_editor.dart';
import 'note_editor_harness.dart'; import 'note_editor_harness.dart';
import 'text_free_guard.dart';
void main() { void main() {
testWidgets( testWidgets(
@ -15,7 +16,7 @@ void main() {
await tester.enterText(find.byType(TextField).first, 'T'); await tester.enterText(find.byType(TextField).first, 'T');
await tester.pump(); await tester.pump();
await tester.tap(find.byTooltip('Exit guided')); await tester.tap(find.bySemanticsLabel('Exit guided'));
await tester.pump(); await tester.pump();
final raw = tester.widget<TextField>(find.byType(TextField)); final raw = tester.widget<TextField>(find.byType(TextField));
@ -38,10 +39,11 @@ void main() {
); );
// Structured template + non-conforming raw text → switching is refused. // Structured template + non-conforming raw text → switching is refused.
await tester.tap(find.text('Guided')); await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
expect(find.textContaining("doesn't match the template"), findsOneWidget); expect(spoken("doesn't match the template"), findsOneWidget);
expect(find.byType(LinearProgressIndicator), findsNothing); // still raw expect(find.byType(LinearProgressIndicator), findsNothing); // still raw
}); });
@ -56,25 +58,28 @@ void main() {
// Bare guided -> raw via the back arrow makes the (still conforming) // Bare guided -> raw via the back arrow makes the (still conforming)
// body the source… // body the source…
await tester.tap(find.byTooltip('Exit guided')); await tester.tap(find.bySemanticsLabel('Exit guided'));
await tester.pump(); await tester.pump();
expect(find.byType(LinearProgressIndicator), findsNothing); expect(find.byType(LinearProgressIndicator), findsNothing);
// …then raw -> guided re-parses the conforming body straight back into // …then raw -> guided re-parses the conforming body straight back into
// the bare stepper — no wizard, since the content isn't empty. // the bare stepper — no wizard, since the content isn't empty.
await tester.tap(find.text('Guided')); await tester.tap(find.bySemanticsLabel('Guided'));
await tester.pump(); await tester.pump();
expect(find.byType(LinearProgressIndicator), findsOneWidget); expect(find.byType(LinearProgressIndicator), findsOneWidget);
expect(find.text('Step 1 of 2'), findsNothing); // wizard was skipped expect(
find.bySemanticsLabel('Step 1 of 2'),
findsNothing,
); // wizard was skipped
}, },
); );
testWidgets('freeform template offers only View and Raw', (tester) async { testWidgets('freeform template offers only View and Raw', (tester) async {
final emitted = await pumpEditor(tester, initialTemplate: blank); final emitted = await pumpEditor(tester, initialTemplate: blank);
expect(find.text('Guided'), findsNothing); expect(find.bySemanticsLabel('Guided'), findsNothing);
expect(find.text('View'), findsOneWidget); expect(find.bySemanticsLabel('View'), findsOneWidget);
expect(find.text('Raw'), findsOneWidget); expect(find.bySemanticsLabel('Raw'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'free text'); await tester.enterText(find.byType(TextField), 'free text');
await tester.pump(); await tester.pump();
@ -89,15 +94,20 @@ void main() {
initialTemplate: spec, initialTemplate: spec,
initialMode: NoteEditorMode.raw, initialMode: NoteEditorMode.raw,
); );
expect(find.text('Guided'), findsOneWidget); // structured: Guided offered expect(
find.bySemanticsLabel('Guided'),
findsOneWidget,
); // structured: Guided offered
await tester.tap(find.text('LLM design spec').first); // open the menu await tester.tap(
find.byIcon(Icons.assignment_outlined).first,
); // open the menu
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Blank').last); await tester.tap(find.byIcon(Icons.note_outlined).last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Freeform now: Guided is no longer offered. // Freeform now: Guided is no longer offered.
expect(find.text('Guided'), findsNothing); expect(find.bySemanticsLabel('Guided'), findsNothing);
}); });
testWidgets('detects a conforming note (no template given) as guided', ( testWidgets('detects a conforming note (no template given) as guided', (
@ -110,7 +120,7 @@ void main() {
find.byType(LinearProgressIndicator), find.byType(LinearProgressIndicator),
findsOneWidget, findsOneWidget,
); // guided by default ); // guided by default
expect(find.byTooltip('Exit guided'), findsOneWidget); expect(find.bySemanticsLabel('Exit guided'), findsOneWidget);
}); });
testWidgets('detects a legacy note (no template given) as freeform raw', ( testWidgets('detects a legacy note (no template given) as freeform raw', (
@ -120,7 +130,7 @@ void main() {
// Non-conforming → blank/raw, no guided stepper offered. // Non-conforming → blank/raw, no guided stepper offered.
expect(find.byType(LinearProgressIndicator), findsNothing); expect(find.byType(LinearProgressIndicator), findsNothing);
expect(find.text('Guided'), findsNothing); expect(find.bySemanticsLabel('Guided'), findsNothing);
}); });
testWidgets('initialMode preview opens directly in the rendered view', ( testWidgets('initialMode preview opens directly in the rendered view', (
@ -147,7 +157,7 @@ void main() {
initialMode: NoteEditorMode.preview, initialMode: NoteEditorMode.preview,
); );
await tester.tap(find.text('Raw')); await tester.tap(find.bySemanticsLabel('Raw'));
await tester.pump(); await tester.pump();
final raw = tester.widget<TextField>(find.byType(TextField)); final raw = tester.widget<TextField>(find.byType(TextField));

Some files were not shown because too many files have changed in this diff Show More