Compare commits

..

23 Commits

Author SHA1 Message Date
f7e24bbb1a Desktop adopts the E2E passphrase once
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 wrapper serves ~/.config/crdt-sync/password once on
/sync-e2e-passphrase; the web app stores it in its keystore, so desktop
todo never asks. Android still unlocks via the lock tile.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:56:07 +01:00
4423937f5c E2E: key cache, CLI password, lock tile
Argon2id runs once per device (keystore key cache); the CLI/MCP read
~/.config/crdt-sync/password; pull-only merges old and encrypted
prefixes; the settings lock tile follows the last sync and resyncs on
save. Pins crdt_sync 0.14.0, crdt_sync_flutter 0.8.0, sync_settings_ui
0.7.2.

Todo: fda71ed1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 09:52:15 +01:00
f4913daaa8 E2E-encrypt todo sync
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
Todo: fda71ed1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 08:39:05 +01:00
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
156 changed files with 5290 additions and 1198 deletions

View File

@ -50,6 +50,7 @@ and sync peer-to-peer through a GitHub repo used as dumb storage.
- lint: `flutter analyze`
- coverage: `flutter test --coverage --reporter=compact`
- 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

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
# This newDsl flag was added by the Flutter template
android.newDsl=false

View File

@ -4,49 +4,12 @@
/// exact-pinned dependency under the freshness gate.
library;
import 'package:todo/cli/cli_mode.dart';
import 'package:todo/data/note.dart';
export 'package:todo/cli/cli_mode.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.
class CliArgs {
/// Creates parsed arguments; see [parse].
@ -58,6 +21,7 @@ class CliArgs {
this.status,
this.dataDir,
this.newNote = false,
this.dryRun = false,
this.opts = (
repo: null,
query: null,
@ -89,12 +53,19 @@ class CliArgs {
opts[arg] = value();
case '--list':
modes.add(CliMode.list);
case '--backfill-created-by':
modes.add(CliMode.backfill);
case '--dry-run':
opts[arg] = 'true';
case '--search':
modes.add(CliMode.search);
opts[arg] = value();
case '--get' || '--delete' || '--close':
modes.add(CliMode.values.byName(arg.substring(2)));
id = value();
case '--set-text':
modes.add(CliMode.setText);
id = value();
case '--set-status':
modes.add(CliMode.setStatus);
id = value();
@ -106,8 +77,9 @@ class CliArgs {
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
if (modes.length > 1) {
throw UsageError(
'--list, --search, --get, --delete, --set-status and --close are '
'mutually exclusive',
'--list, --search, --get, --delete, --set-status, --set-text, '
'--close and '
'--backfill-created-by are mutually exclusive',
);
}
final mode = modes.isEmpty ? CliMode.add : modes.single;
@ -128,6 +100,7 @@ class CliArgs {
opts.containsKey('--new-note') &&
(opts['--repo'] != null ||
(throw UsageError('--new-note needs --repo'))),
dryRun: opts.containsKey('--dry-run'),
opts: (
repo: _repo(opts['--repo']),
query: _nonBlank(opts['--search'], '--search'),
@ -141,7 +114,8 @@ class CliArgs {
/// The selected 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;
/// 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.
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.
final ({
String? repo,
@ -174,7 +151,7 @@ class CliArgs {
static const _adding = 'when adding a note';
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}),
'--by': (_adding, {CliMode.add}),
'--repo': (
@ -183,6 +160,7 @@ class CliArgs {
),
'--evidence': ('to --close', {CliMode.close}),
'--new-note': (_adding, {CliMode.add}),
'--dry-run': ('to --backfill-created-by', {CliMode.backfill}),
};
static void _checkScopes(Iterable<String> flags, CliMode mode) {

View File

@ -38,6 +38,7 @@ class CliCommands {
repo: repo,
doneCheck: args.opts.doneCheck,
by: args.opts.by,
createdBy: 'claude',
);
await session.repository.upsert(note);
if (!await _push('note')) return 1;
@ -57,6 +58,7 @@ class CliCommands {
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
createdBy: 'claude',
);
await _save(added.note);
if (!await _push('note')) return 1;
@ -79,6 +81,7 @@ class CliCommands {
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
createdBy: 'claude',
);
await _save(added.note);
if (!await _push('note')) return 1;
@ -133,6 +136,15 @@ class CliCommands {
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.
Future<int> close(String ref, String evidence) async {
final note = await _pullAndFind(ref);
@ -142,6 +154,35 @@ class CliCommands {
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 {
_report(await session.sync(push: true));
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;
}
String? text;
if (args.mode == CliMode.add) {
if (args.mode == CliMode.add || args.mode == CliMode.setText) {
text = args.text ?? await readStdin();
if (text.trim().isEmpty) {
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.get => await cli.get(args.id!),
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.backfill => await cli.backfillCreatedBy(
claudeNodeIds(env),
dryRun: args.dryRun,
),
// Only delete is left: help returned above.
_ => await cli.delete(args.id!),
};

View File

@ -29,11 +29,38 @@ String defaultDataDir(Map<String, String> env) {
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.
class CliSession {
/// Wraps already-open parts; [open] is the normal way in. Public so a test
/// can subclass the session and script [sync]'s outcome.
CliSession(this.dataDir, this.repository, this._firebase);
CliSession(
this.dataDir,
this.repository,
this._firebase, {
this.passphraseFile,
this.sharedPasswordFile,
});
/// The fleet-wide crdt-sync password the Python daemons read too
/// (`~/.config/crdt-sync/password`), found from the seed's config root.
final File? sharedPasswordFile;
/// Where the passphrase is read from besides the environment: next to the
/// seeded Firebase session, e.g. `~/.config/todo/e2e_passphrase`.
final File? passphraseFile;
/// File holding this install's persisted CRDT node id.
static const nodeIdFileName = 'node_id';
@ -92,7 +119,17 @@ class CliSession {
auth: auth,
httpClient: httpClient,
);
return CliSession(dataDir, repository, firebase);
return CliSession(
dataDir,
repository,
firebase,
passphraseFile: File(
p.join(p.dirname(seedCredentials), e2ePassphraseFileName),
),
sharedPasswordFile: File(
p.join(p.dirname(p.dirname(seedCredentials)), 'crdt-sync', 'password'),
),
);
}
/// Pull every peer's log and merge it; with [push], also publish this
@ -100,15 +137,66 @@ class CliSession {
///
/// Without [push] nothing is written anywhere remote, and the revision cache
/// is bypassed so a read always sees every peer's current log.
Future<SyncResult> sync({required bool push}) {
Future<SyncResult> sync({required bool push}) async {
final plainService = SyncService(
stateStore: push
? PersistedSyncStateStore(
FileLogPersistence(File(p.join(dataDir, syncStateFileName))),
)
: null,
);
final e2e = await openEncryptedStore(
store: _remote(push: push),
oldPrefix: plainService.notesDir,
readPassphrase: _readPassphrase,
);
if (!push) {
return const SyncService().sync(repository, PullOnlyStore(_firebase));
// Read-only during the migration: old builds still write the plaintext
// prefix, so merge it, then the encrypted one when this host has a key.
final plain = await const SyncService().sync(
repository,
_remote(push: false),
);
if (e2e is! E2eReady) return plain;
final layout = E2eLayout.of(plainService.notesDir);
return await SyncService(
notesDir: layout.prefix,
revsPath: layout.revsPath,
).sync(repository, e2e.store);
}
return SyncService(
stateStore: PersistedSyncStateStore(
FileLogPersistence(File(p.join(dataDir, syncStateFileName))),
),
).sync(repository, _firebase);
if (e2e is! E2eReady) {
throw StateError(
'sync is locked: put the crdt-sync passphrase in '
'$e2ePassphraseEnv or ${passphraseFile?.path ?? e2ePassphraseFileName}',
);
}
final layout = E2eLayout.of(plainService.notesDir);
return await SyncService(
notesDir: layout.prefix,
revsPath: layout.revsPath,
stateStore: plainService.stateStore,
).sync(repository, e2e.store);
}
RemoteStore _remote({required bool push}) =>
push ? _firebase : PullOnlyStore(_firebase);
/// Environment variable holding the shared crdt-sync passphrase.
static const e2ePassphraseEnv = 'TODO_E2E_PASSPHRASE';
/// File name of the passphrase, beside the seeded Firebase session.
static const e2ePassphraseFileName = 'e2e_passphrase';
Future<String?> _readPassphrase() async {
final fromEnv = Platform.environment[e2ePassphraseEnv];
if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv;
for (final file in [passphraseFile, sharedPasswordFile]) {
if (file != null && file.existsSync()) {
final text = (await file.readAsString()).trim();
if (text.isNotEmpty) return text;
}
}
return null;
}
/// Releases the log and the HTTP client.

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)
--delete <id> pull, tombstone the note, 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>
pull, set status done + evidence (commit sha + done
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
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
-h, --help this text

View File

@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
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) => [
if (note.id.length <= _minPrefix)
note.id
@ -42,6 +42,7 @@ String listLine(Note note) => [
note.priority.name,
note.repo ?? '-',
_title(note),
note.createdBy ?? 'kuhy',
].join('\t');
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.by case final v?) 'by: $v',
if (note.evidence case final v?) 'evidence: $v',
'created_by: ${note.createdBy ?? 'kuhy'}',
'---',
note.text,
].join('\n');

View File

@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart';
/// sibling to `changesets/<nodeId>.json`.
const String kAdvancedModeRemotePath = 'settings/advancedMode';
/// App-wide, non-sync preferences (currently just `advancedMode`).
/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
class AppSettings {
/// 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
/// 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.
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 _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
@ -44,6 +53,7 @@ class AppSettings {
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
return AppSettings(
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
showClaudeNotes: prefs.getBool(_kShowClaude) ?? false,
advancedModeUpdatedAt: updatedAtMillis == null
? null
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
@ -71,6 +81,7 @@ class AppSettings {
final updated = AppSettings(
advancedMode: value,
advancedModeUpdatedAt: updatedAt,
showClaudeNotes: showClaudeNotes,
);
if (client != null) {
try {
@ -89,6 +100,17 @@ class AppSettings {
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
/// local one, applies it locally and returns the reconciled settings.
/// Returns this instance unchanged when [client] is null, there is
@ -100,9 +122,7 @@ class AppSettings {
try {
final remote = await client.getStringMap(kAdvancedModeRemotePath);
if (!remote.containsKey('value')) return this;
final remoteUpdatedMillis = int.tryParse(
remote['updatedAtMillis'] ?? '',
);
final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? '');
if (remoteUpdatedMillis == null) return this;
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
remoteUpdatedMillis,
@ -110,6 +130,7 @@ class AppSettings {
final candidate = AppSettings(
advancedMode: remote['value'] == 'true',
advancedModeUpdatedAt: remoteUpdatedAt,
showClaudeNotes: showClaudeNotes,
);
final adopted = adopt(candidate);
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,
);
}

View File

@ -73,11 +73,7 @@ class LegacySqliteMigration {
}
// coverage:ignore-end
static Future<void> _onUpgrade(
CrdtTableExecutor db,
int from,
int to,
) async {
static Future<void> _onUpgrade(CrdtTableExecutor db, int from, int to) async {
if (from < 2) {
await db.execute(
'ALTER TABLE notes ADD COLUMN status INTEGER NOT NULL DEFAULT 0',

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'};
/// An immutable idea/note record.
@ -99,6 +99,7 @@ class Note {
this.doneCheck,
this.by,
this.evidence,
this.createdBy,
});
/// 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).
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.
bool get isRepoTask => repo != null;
@ -162,6 +169,7 @@ class Note {
String? doneCheck,
String? by,
String? evidence,
String? createdBy,
}) {
return Note(
id: id,
@ -174,6 +182,7 @@ class Note {
doneCheck: doneCheck ?? this.doneCheck,
by: by ?? this.by,
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.repo,
this.repoTasksOnly = false,
this.hideClaude = false,
this.createdFrom,
this.createdTo,
this.updatedFrom,
@ -58,6 +59,10 @@ class NoteFilter {
/// Only notes that have a repo (repo tasks). Ideas are excluded.
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.
final DateTime? createdFrom;
@ -104,6 +109,7 @@ class NoteFilter {
Set<Status>? statuses,
String? repo,
bool? repoTasksOnly,
bool? hideClaude,
DateTime? createdFrom,
DateTime? createdTo,
DateTime? updatedFrom,
@ -117,6 +123,7 @@ class NoteFilter {
statuses: statuses ?? this.statuses,
repo: repo ?? this.repo,
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
hideClaude: hideClaude ?? this.hideClaude,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
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.repoTasksOnly && note.repo == null) return false;
if (filter.hideClaude && note.isClaudeMade) return false;
if (filter.priorities.isNotEmpty &&
!filter.priorities.contains(note.priority)) {
return false;
@ -165,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
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) {
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) {
case NoteSort.createdDesc:
return (a, b) => b.createdAt.compareTo(a.createdAt);

View File

@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
const _by = 'by';
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
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
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.by case final v?) _by: (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.
@ -48,6 +52,7 @@ Note noteFromRecord(Record record) {
doneCheck: task(_doneCheck),
by: task(_by),
evidence: task(_evidence),
createdBy: task(kCreatedByField),
);
}

View File

@ -1,7 +1,8 @@
import 'dart:async';
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_bulk.dart';
import 'package:todo/data/note_import.dart';
import 'package:todo/data/note_query.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
/// share one fresh monotonic clock (they are written together).
Future<void> upsert(Note note) async {
await _store.upsert(
Record(id: note.id, fields: noteFields(note, _store.nextHlc())),
);
await _put(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
@ -142,10 +191,10 @@ class NoteRepository {
// the import wins locally; the clock decides whether it wins on *other*
// devices, and an import must not win there simply by being recent.
if (local == null) {
await _store.upsert(recordAtNoteTime(note, _nodeId));
await _put(recordAtNoteTime(note, _nodeId));
added++;
} else if (note.updatedAt.isAfter(local.updatedAt)) {
await _store.upsert(recordAtNoteTime(note, _nodeId));
await _put(recordAtNoteTime(note, _nodeId));
updated++;
} else {
skipped++;
@ -165,27 +214,10 @@ class NoteRepository {
Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.priorityDesc,
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.
Stream<int> watchCount() => _watch(_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;
}
Stream<int> watchCount() => watchLog(_store.changes, _liveCount);
/// This device's stable node id. Names its file in the sync repo so two
/// 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,
Priority priority = Priority.medium,
String? by,
String? createdBy,
}) async {
final section = repoSection(title, body);
final open = (await repository.listNotes(
@ -99,6 +100,7 @@ Future<RepoAdd> addRepoTask(
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
appended: false,
);

View File

@ -26,16 +26,24 @@ typedef SplitAdd = ({Note note, SplitKind kind, String? why});
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
/// The open `## title` headings of [text], outside code fences.
Iterable<String> openSections(String text) sync* {
/// Every `## title` heading of [text] outside code fences, open or done.
Iterable<RegExpMatch> _sections(String text) sync* {
var fenced = false;
for (final line in text.split('\n')) {
if (line.trimLeft().startsWith('```')) fenced = !fenced;
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].
Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
final hits =
@ -69,6 +77,7 @@ Future<SplitAdd> addSeparateTask(
String? doneCheck,
Priority priority = Priority.medium,
String? by,
String? createdBy,
Future<bool> Function(Note note)? busy,
}) async {
final section = repoSection(title, body);
@ -105,6 +114,7 @@ Future<SplitAdd> addSeparateTask(
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
kind: why == null ? SplitKind.added : SplitKind.fellBack,
why: why,

View File

@ -7,6 +7,11 @@ import 'package:path/path.dart' as p;
import 'package:todo/desktop/static_content_type.dart';
import 'package:todo/desktop/wrapper_image_routes.dart';
import 'package:todo/sync/e2e_passphrase_source.dart'
show kSyncE2ePassphrasePath;
export 'package:todo/sync/e2e_passphrase_source.dart'
show kSyncE2ePassphrasePath;
export 'wrapper_paths.dart';
@ -86,6 +91,9 @@ class WrapperServer {
/// wrappers also serve, which stays opt-in.
bool _todoCredentialsServed = false;
/// Whether [kSyncE2ePassphrasePath] has served the passphrase yet.
bool _e2ePassphraseServed = false;
/// `todo`'s own seeded Firebase session, written by
/// `crdt-sync/tool/seed_session.py --app todo` in the same
/// `{id_token, refresh_token, expires_at}` shape every other app's
@ -184,6 +192,9 @@ class WrapperServer {
if (path == kSyncCredentialsPath) {
return await _syncCredentials(request);
}
if (path == kSyncE2ePassphrasePath) {
return await _syncE2ePassphrase(request);
}
if (await images.handle(request)) return;
return await _static(request, path);
}

View File

@ -121,4 +121,23 @@ extension _SyncRoutes on WrapperServer {
// through must remain retryable rather than burning the one serve.
_todoCredentialsServed = true;
}
/// Serves the shared crdt-sync password as the E2E passphrase, once per
/// wrapper process, like [_syncCredentials]: the web app stores it in its
/// keystore on first sync and never asks for it.
Future<void> _syncE2ePassphrase(HttpRequest request) async {
final passwordFile = File(p.join(syncConfigDir, 'password'));
if (_e2ePassphraseServed || !passwordFile.existsSync()) {
request.response.statusCode = HttpStatus.notFound;
return;
}
final text = (await passwordFile.readAsString()).trim();
if (text.isEmpty) {
request.response.statusCode = HttpStatus.notFound;
return;
}
request.response.headers.contentType = ContentType.text;
request.response.write(text);
_e2ePassphraseServed = true;
}
}

View File

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

View File

@ -5,6 +5,7 @@
/// a board that is down must not lose the task that was just saved.
library;
import 'dart:async';
import 'dart:convert';
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].
class BoardClient {
/// [client] is injectable so tests need no network.
BoardClient({http.Client? client, Uri? base})
: _http = client ?? http.Client(),
base = base ?? kBoardBase;
/// [client] is injectable so tests need no network. [wait] bounds how
/// long Start waits for the board to see a merged section (its note cache
/// lags ~10 s, T1863) and to confirm a Start whose reply outran
/// [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;
/// The board's origin; also the `Origin` header of a POST.
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 {
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].
Future<StartResult> start(String noteId, String title) async {
/// Clicks Start on the note's last startable item titled [title]. With
/// [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 {
final card = (await _cards(noteId)).reversed
.where((c) => c['title'] == title && _startable(c))
.firstOrNull;
final last = sections == null ? null : _item(noteId, sections - 1);
bool seen(List<Map<String, dynamic>> cs) =>
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');
final page = await _http.get(base).timeout(_timeout);
final token = _csrf.firstMatch(page.body)?.group(1);
if (token == null) return _no('no CSRF token on the board page');
final id = card['id']! as String;
final res = await _http
.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)}');
return await _post(noteId, id, token);
} on Object catch (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)
.cast<Map<String, dynamic>>()
.any((a) => a['id'] == 'start' && a['enabled'] == true);

View File

@ -17,7 +17,7 @@ class TaskService {
final BoardClient? _board;
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());
_tail = run.then((_) {}, onError: (_) {});
return run;
@ -44,8 +44,14 @@ class TaskService {
}
}
/// `add_task`: joins the repo's open note (by: claude), or creates it.
Future<String> add(Map<String, dynamic> args) => _serial(() async {
/// `add_task`: joins the repo's open note (by: claude), or creates it. A
/// 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 repo = a.repo(required: true)!;
final title = a.required('title');
@ -70,9 +76,11 @@ class TaskService {
doneCheck: a.optional('done_check'),
priority: a.priority() ?? Priority.medium,
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.
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.
typedef SaveNote = Future<String> Function(Note note, String verb);
/// Adds a separate task, merging into a duplicate's note when it is idle,
/// and clicks Start on the board after a merge that may run.
Future<String> addSplit(
/// A saved split add: its reply so far, and the board Start still to run.
/// The caller runs [start] after releasing its lock, since the board learns
/// 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,
BoardClient? board,
SaveNote save, {
@ -30,19 +35,33 @@ Future<String> addSplit(
doneCheck: doneCheck,
priority: priority,
by: 'claude',
createdBy: 'claude',
busy: board == null ? null : (n) => board.running(n.id),
);
if (added.kind != SplitKind.merged) {
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 block = startBlock(doneCheck, body);
if (block != null) return '$reply\nstart: skipped ($block)';
final start = board == null
? (ok: false, reason: 'no board configured', item: null)
: await board.start(added.note.id, title.trim());
if (start.ok) return '$reply\nstart: ok ${start.item}';
return '$reply\nstart: {ok: false, reason: "${start.reason}"}; '
'the merge is saved, click Start on the board by hand';
if (block != null) {
return (reply: '$reply\nstart: skipped ($block)', start: null);
}
Future<String> start() async {
final s = board == null
? (ok: false, reason: 'no board configured', item: null)
: 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(
'list_tasks',
'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,
'status': JsonSchema.string(

View File

@ -0,0 +1,38 @@
/// Where todo gets the shared crdt-sync passphrase its E2E key comes from.
///
/// The keystore first. On the desktop (a web build behind the local
/// wrapper) an empty keystore adopts the wrapper's one-shot
/// `/sync-e2e-passphrase` route, which serves `~/.config/crdt-sync/password`,
/// so the desktop app never asks. Android has no wrapper (`Uri.base` is
/// `file:///`) and is unlocked through the settings lock tile instead.
library;
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
show kE2ePassphraseKey, kSecureStorage, saveE2ePassphrase;
import 'package:http/http.dart' as http;
/// Path of the wrapper's one-shot passphrase route.
const kSyncE2ePassphrasePath = '/sync-e2e-passphrase';
/// Returns the passphrase, adopting the wrapper's once; null when none.
Future<String?> readE2ePassphrase({Uri? base, http.Client? client}) async {
final stored = await kSecureStorage.read(key: kE2ePassphraseKey);
if (stored != null && stored.isNotEmpty) return stored;
final origin = base ?? Uri.base;
if (origin.scheme != 'http' && origin.scheme != 'https') return null;
final httpClient = client ?? http.Client();
try {
final response = await httpClient.get(
origin.resolve(kSyncE2ePassphrasePath),
);
final text = response.body.trim();
if (response.statusCode != 200 || text.isEmpty) return null;
await saveE2ePassphrase(text);
return text;
} on Object {
// No wrapper, or it already served: this device is simply locked.
return null;
} finally {
if (client == null) httpClient.close();
}
}

View File

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

View File

@ -7,15 +7,26 @@
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart' show keystoreKeyCache;
import 'package:flutter/foundation.dart' show ValueNotifier, visibleForTesting;
import 'package:http/http.dart' as http;
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/e2e_passphrase_source.dart';
import 'package:todo/sync/firebase_backend.dart';
import 'package:todo/sync/sync_service.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/sync/sync_state_factory.dart';
/// Argon2id cost for new headers; only tests lower it.
@visibleForTesting
Argon2Params e2eArgon2Params = Argon2Params.recommended;
/// Whether the last sync found no usable end-to-end key; drives the lock
/// tile in settings.
final ValueNotifier<bool> e2eLocked = ValueNotifier<bool>(false);
/// Bundles a [SyncResult] with the [AppSettings] as reconciled against the
/// same Firebase session — so a settings change made on another device is
/// picked up without a second authenticated round trip.
@ -44,8 +55,12 @@ class SyncRunResult {
/// rather than each concern (notes, settings, analytics) opening and
/// closing its own.
///
/// [firebaseFactory] and [stateStore] exist for tests, which must not reach
/// the OS keystore or an application-support directory.
/// [firebaseFactory], [stateStore] and [readPassphrase] exist for tests, which
/// must not reach the OS keystore or an application-support directory.
///
/// Everything pushed is end-to-end encrypted under the shared crdt-sync
/// passphrase; without a usable key the run is a no-op flagged
/// [SyncResult.locked] rather than a plaintext sync.
Future<SyncRunResult> runSync(
NoteRepository repository,
SyncSettings settings, {
@ -54,6 +69,7 @@ Future<SyncRunResult> runSync(
http.Client? httpClient,
Future<FirebaseRestClient?> Function()? firebaseFactory,
SyncStateStore? stateStore,
Future<String?> Function()? readPassphrase,
}) async {
final github = GitHubClient(
owner: settings.owner,
@ -72,12 +88,29 @@ Future<SyncRunResult> runSync(
stateStore: stateStore ?? await openSyncStateStore(),
// coverage:ignore-end
);
final result = await service.sync(
repository,
firebase == null
? github
: MirrorStore(primary: firebase, mirror: github),
final backend = firebase == null
? github
: MirrorStore(primary: firebase, mirror: github);
final e2e = await openEncryptedStore(
store: backend,
oldPrefix: service.notesDir,
readPassphrase: readPassphrase ?? readE2ePassphrase,
params: e2eArgon2Params,
// Argon2id once per device, not once per tick.
keyCache: keystoreKeyCache(service.notesDir),
);
e2eLocked.value = e2e is! E2eReady;
if (e2e is! E2eReady) {
return const SyncRunResult(
syncResult: SyncResult(mergedDevices: 0, pushed: false, locked: true),
);
}
final layout = E2eLayout.of(service.notesDir);
final result = await SyncService(
notesDir: layout.prefix,
revsPath: layout.revsPath,
stateStore: service.stateStore,
).sync(repository, e2e.store);
await analytics?.flush(firebase);
return SyncRunResult(
syncResult: result.withFirebaseConnected(value: firebase != null),

View File

@ -11,6 +11,7 @@ class SyncResult {
this.skippedFiles = const [],
this.skippedUnchanged = 0,
this.firebaseConnected = false,
this.locked = false,
});
/// How many other devices' logs were pulled and merged.
@ -41,6 +42,10 @@ class SyncResult {
/// device dropping out.
final int skippedUnchanged;
/// True when end-to-end encryption has no usable key on this device, so
/// nothing was pulled, merged or pushed.
final bool locked;
/// Returns a copy with [firebaseConnected] set. [SyncService] never sets
/// it -- only [runSync], which is the layer that knows which [RemoteStore]
/// it built -- so this is the seam that lets it do that without
@ -51,6 +56,7 @@ class SyncResult {
skippedFiles: skippedFiles,
skippedUnchanged: skippedUnchanged,
firebaseConnected: value,
locked: locked,
);
@override
@ -73,7 +79,11 @@ class SyncResult {
/// two never share a path and an old client can't misread a new file.
class SyncService {
/// Creates a [SyncService], optionally overriding [notesDir].
const SyncService({this.notesDir = 'todo-sync/notes', this.stateStore});
const SyncService({
this.notesDir = 'todo-sync/notes',
this.stateStore,
this.revsPath,
});
/// Directory in the repo under which per-device note-log files live.
final String notesDir;
@ -87,7 +97,11 @@ class SyncService {
final SyncStateStore? stateStore;
/// Where revisions live, as a sibling of [notesDir].
String get revsDir => defaultRevsPath(notesDir);
/// Revision cache directory; null derives it from [notesDir].
final String? revsPath;
/// Where peers publish their revisions.
String get revsDir => revsPath ?? defaultRevsPath(notesDir);
/// Runs a full pull-merge-push cycle. Safe to call repeatedly.
///
@ -123,7 +137,15 @@ class SyncService {
skippedUnchanged++;
continue;
}
final text = await remote.getFileText('$notesDir/$name');
final String? text;
try {
text = await remote.getFileText('$notesDir/$name');
} on E2eDecryptError {
// Wrong key, tampered blob or a newer format: skipped and reported,
// never merged and never fatal.
skipped.add(name);
continue;
}
final remoteLog = text == null ? null : _decodeLog(text);
if (remoteLog == null) {
// A vanished or undecodable peer file must not abort the sync, but

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;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/ui/motion.dart';
/// A route for [builder]: fade plus a short rise over [AppDuration.slow],
/// instant under the OS "remove animations" setting.
Route<T> appRoute<T>(BuildContext context, WidgetBuilder builder) =>
_AppPageRoute<T>(
builder: builder,
duration: motionDuration(context, AppDuration.slow),
);
/// A route for [builder] that switches screens instantly: no fade, no slide.
///
/// Without a GPU the desktop web build repaints the whole 4K canvas in
/// software on every frame, so a 320 ms transition cost ~600 ms of main
/// thread and a 260-300 ms frame stall on every tab switch and note open.
/// 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> {
_AppPageRoute({required super.builder, required this.duration});
final Duration duration;
_AppPageRoute({required super.builder});
@override
Duration get transitionDuration => duration;
Duration get transitionDuration => Duration.zero;
@override
Duration get reverseTransitionDuration => duration;
Duration get reverseTransitionDuration => Duration.zero;
@override
Widget buildTransitions(
@ -30,21 +28,5 @@ class _AppPageRoute<T> extends MaterialPageRoute<T> {
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget 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,
),
);
}
) => child;
}

View File

@ -44,18 +44,24 @@ Future<List<XFile>> pickAttachments(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('Camera'),
leading: const Icon(
Icons.photo_camera_outlined,
semanticLabel: 'Camera',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Gallery'),
leading: const Icon(
Icons.photo_library_outlined,
semanticLabel: 'Gallery',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.gallery),
),
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: const Text('File'),
leading: const Icon(
Icons.insert_drive_file_outlined,
semanticLabel: 'File',
),
onTap: () => Navigator.pop(context, AttachmentPickSource.file),
),
],
@ -102,8 +108,7 @@ class AttachButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Attach',
icon: const Icon(Icons.attach_file),
icon: const Icon(Icons.attach_file, semanticLabel: 'Attach'),
onPressed: () async {
final files = await (picking ?? pickAttachments)(context);
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.
AttachButton(onFiles: onAttachFiles, picking: attachmentPicking),
IconButton(
tooltip: 'New note',
onPressed: onNewNote,
icon: const Icon(Icons.check),
icon: const Icon(Icons.check, semanticLabel: 'New note'),
),
if (advanced)
IconButton(
tooltip: 'Sync',
onPressed: syncing ? null : onSync,
icon: syncing
? const SizedBox(
@ -100,12 +98,11 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
: const Icon(Icons.sync, semanticLabel: 'Sync'),
),
IconButton(
tooltip: 'Notes',
onPressed: onOpenList,
icon: const Icon(Icons.list),
icon: const Icon(Icons.list, semanticLabel: 'Notes'),
),
// Live count of stored notes, proving local persistence. Sits directly
// 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),
IconButton(
tooltip: 'Settings',
onPressed: onOpenSettings,
icon: const Icon(Icons.settings),
icon: const Icon(Icons.settings, semanticLabel: 'Settings'),
),
],
);

View File

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

View File

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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_footer.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_input.dart';
import 'package:todo/ui/note_form.dart';
@ -194,7 +194,14 @@ class _CaptureScreenState extends State<CaptureScreen>
Future<void> _sync() async {
final settings = _settings ?? await SyncSettings.load();
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();
return;
}
@ -215,12 +222,7 @@ class _CaptureScreenState extends State<CaptureScreen>
if (outcome.ok) 'detail': outcome.detail,
},
);
_showSnack(
outcome.ok
? 'Synced: ${outcome.detail}'
: 'Sync failed: ${outcome.detail}',
isError: !outcome.ok,
);
_showSyncCue(outcome);
} finally {
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
/// the danger hue and the longer duration, because an error the user
/// 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 (isError) {
showError(context, message);
} else {
showToast(context, message);
}
final spoken = outcome.ok
? 'Synced: ${outcome.detail}'
: 'Sync failed: ${outcome.detail}';
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

View File

@ -98,6 +98,7 @@ class SyncStatusStore {
/// precisely how a desktop stuck syncing over the GitHub mirror alone
/// went unnoticed for days.
String describeSyncResult(SyncResult result) {
if (result.locked) return '🔒';
final skipped = result.skippedFiles.length;
final backend = result.firebaseConnected
? ''

View File

@ -44,16 +44,16 @@ class CaptureStatusFooter extends StatelessWidget {
if (status == null || status.ok) {
return const SizedBox.shrink();
}
// Glyph + time in the danger hue; the error itself is spoken.
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
child: _cueLine(
Icons.sync_problem,
formatSyncTime(status.time),
'Sync failed at ${formatSyncTime(status.time)} · '
'${status.detail}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
theme.textTheme.bodySmall,
theme.colorScheme.error,
),
);
},
@ -63,15 +63,45 @@ class CaptureStatusFooter extends StatelessWidget {
padding: const EdgeInsets.only(top: 4),
child: ValueListenableBuilder<DateTime?>(
valueListenable: lastSavedAt,
builder: (context, savedAt, _) => Text(
savedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${formatSyncTime(savedAt)}',
style: theme.textTheme.bodySmall,
),
// Before the first save: the save glyph alone, muted.
builder: (context, savedAt, _) => savedAt == null
? _cueLine(
Icons.save_outlined,
'',
'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.detail,
this.reconciled,
this.degraded = false,
});
/// Whether the pass completed without throwing.
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.
final String detail;
@ -90,10 +95,15 @@ class CaptureSyncRunner {
);
final detail = describeSyncResult(result.syncResult);
await status.record(ok: true, detail: detail, live: live());
final synced = result.syncResult;
return CaptureSyncOutcome(
ok: true,
detail: detail,
reconciled: result.appSettings,
degraded:
synced.locked ||
!synced.firebaseConnected ||
synced.skippedFiles.isNotEmpty,
);
} on Exception catch (e) {
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/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
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/images/dufs_image_client.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.
const kAddLoginCommand =
@ -34,8 +36,7 @@ class CloudImagesTile extends StatelessWidget {
@override
Widget build(BuildContext context) => ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Cloud images'),
subtitle: const Text('Where attached images and files upload (dufs)'),
leading: const Icon(Icons.cloud, semanticLabel: 'Cloud images'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
@ -69,7 +70,7 @@ class CloudImagesScreen extends StatefulWidget {
class _CloudImagesScreenState extends State<CloudImagesScreen> {
final _password = TextEditingController();
ImageStoreStatus? _status;
String? _checkResult;
StatusCue? _checkResult;
bool _checking = false;
@override
@ -98,7 +99,11 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
final password = _password.text.trim();
setState(() => _checking = true);
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) {
final client = DufsImageClient(
DufsLogin(user: kDufsImagesUser, password: password),
@ -106,9 +111,17 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
);
final check = await client.check();
result = switch (check) {
DufsCheck.ok => 'Saved — dufs accepted the login',
DufsCheck.rejected => 'Saved, but dufs rejected the password',
DufsCheck.unreachable => 'Saved — dufs is unreachable right now',
DufsCheck.ok => const StatusCue.ok('Saved — dufs accepted the login'),
DufsCheck.rejected => const StatusCue(
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
// 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) {
final status = _status;
return Scaffold(
appBar: AppBar(title: const Text('Cloud images')),
appBar: AppBar(
title: const Icon(Icons.cloud_outlined, semanticLabel: 'Cloud images'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -138,36 +153,78 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
controller: _password,
obscureText: true,
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),
FilledButton(
onPressed: _checking ? null : _saveAndTest,
child: const Text('Save & test'),
child: const Icon(Icons.save, semanticLabel: 'Save & test'),
),
if (_checkResult case final result?) ...[
const SizedBox(height: 8),
Text(result),
StatusCueView(result),
],
const SizedBox(height: 16),
],
if (status != null) ...[
Text(
status.configured
? 'Login: configured'
: 'Login: missing — create it on the PC with',
// Shape + colour: a filled check, or a red error ring followed by
// a copy button that puts the fixing command on the clipboard.
Row(
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),
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),
Text(
'Images are shrunk to 2560px JPEGs without location data before '
'they leave this device. Other files upload unchanged, at any '
'size.',
style: Theme.of(context).textTheme.bodySmall,
// Shrunk + no location, as glyphs; the sentence is spoken only.
const Row(
children: [
Icon(
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/sync/run_sync.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/status_cue.dart';
part 'github_mirror_screen_widget.dart';
@ -27,7 +28,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
);
bool _testing = false;
String? _status;
StatusCue? _status;
@override
void dispose() {
@ -49,8 +50,13 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
Future<void> _connectGitHub() async {
final clientId = _clientId.text.trim();
if (clientId.isEmpty) {
setState(() => _status = 'Enter the OAuth App client id first.');
return;
return _show(
const StatusCue(
Icons.key_off,
'Enter the OAuth App client id first.',
error: true,
),
);
}
final auth = GitHubDeviceAuth(
clientId: clientId,
@ -67,20 +73,19 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
if (token != null && token.isNotEmpty) {
setState(() {
_token.text = token;
_status = 'Connected — syncing…';
_status = const StatusCue(Icons.sync, 'Connected — syncing…');
});
await _current.save();
await _syncAfterConnect();
}
} on Exception catch (e) {
if (mounted) setState(() => _status = 'Could not start device flow: $e');
_show(StatusCue.failed('Could not start device flow: $e'));
} finally {
auth.close();
}
}
/// Runs a sync right after connecting so the user's notes download
/// immediately and they get clear confirmation it worked.
/// Syncs right after connecting so notes download at once, with a cue.
Future<void> _syncAfterConnect() async {
final s = _current;
try {
@ -96,19 +101,29 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
widget.appSettings.value = widget.appSettings.value.adopt(
run.appSettings,
);
if (mounted) {
setState(
() => _status =
'Connected and synced (merged '
'${run.syncResult.mergedDevices} device(s)). Your notes are '
'up to date.',
);
}
_show(
StatusCue.ok(
'Connected and synced (merged '
'${run.syncResult.mergedDevices} device(s)). Your notes are '
'up to date.',
),
);
} 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 {
setState(() {
_testing = true;
@ -123,13 +138,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
);
try {
final ok = await client.canAccessRepo();
setState(
() => _status = ok
? 'Connected — repo is reachable.'
: 'Could not access ${s.owner}/${s.repo}. Check token scope.',
_show(
ok
? const StatusCue.ok('Connected — repo is reachable.')
: StatusCue(
Icons.lock,
'Could not access ${s.owner}/${s.repo}. Check token scope.',
error: true,
),
);
} on Exception catch (e) {
setState(() => _status = 'Error: $e');
_show(StatusCue.failed('Error: $e'));
} finally {
client.close();
if (mounted) setState(() => _testing = false);
@ -145,29 +164,27 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Advanced sync (GitHub)')),
appBar: AppBar(
title: const Icon(
Icons.cloud_sync,
semanticLabel: 'Advanced sync (GitHub)',
),
),
body: ListView(
padding: const EdgeInsets.all(16),
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(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
child: IconButton.filled(
onPressed: _connectGitHub,
icon: const Icon(Icons.login),
label: const Text('Connect GitHub'),
icon: const Icon(Icons.login, semanticLabel: 'Connect GitHub'),
),
),
const SizedBox(height: 16),
TextField(
controller: _owner,
decoration: const InputDecoration(
labelText: 'GitHub owner',
prefixIcon: Icon(Icons.person, semanticLabel: 'GitHub owner'),
border: OutlineInputBorder(),
),
),
@ -175,7 +192,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
TextField(
controller: _repo,
decoration: const InputDecoration(
labelText: 'Repository name',
prefixIcon: Icon(Icons.folder, semanticLabel: 'Repository name'),
border: OutlineInputBorder(),
),
),
@ -183,8 +200,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
TextField(
controller: _clientId,
decoration: const InputDecoration(
labelText: 'OAuth App client id',
helperText: 'Leave as the baked-in default unless self-hosting',
prefixIcon: Icon(Icons.key, semanticLabel: 'OAuth App client id'),
border: OutlineInputBorder(),
),
),
@ -193,15 +209,17 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
controller: _token,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Access token (fallback)',
helperText: 'Contents: read/write on the sync repo only',
prefixIcon: Icon(
Icons.vpn_key,
semanticLabel: 'Access token (fallback)',
),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
IconButton.outlined(
onPressed: _testing ? null : _test,
icon: _testing
? const SizedBox(
@ -209,20 +227,21 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi_tethering),
label: const Text('Test connection'),
: const Icon(
Icons.wifi_tethering,
semanticLabel: 'Test connection',
),
),
const SizedBox(width: 12),
FilledButton.icon(
IconButton.filled(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Save'),
icon: const Icon(Icons.save, semanticLabel: 'Save'),
),
],
),
if (_status != null) ...[
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 'package:design_system/design_system.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.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.
class ImageAttachController {
@ -73,11 +73,15 @@ Future<void> attachImages(
target.insert([for (final ref in refs) ref.imageMarkdown]);
if (!context.mounted) return;
if (failure != null) {
showError(context, failure);
showCue(context, Icons.cloud_off, failure, error: true);
} else if (notImages > 0) {
showError(
// A broken-image glyph plus how many were refused.
showCue(
context,
Icons.broken_image_outlined,
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]);
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_scope.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/image_embed.dart';
@ -51,7 +52,9 @@ class _FileEmbedState extends State<FileEmbed> {
Future<void> _open(ImageSource source) async {
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
@ -73,9 +76,10 @@ class _FileEmbedState extends State<FileEmbed> {
style: widget.compact ? theme.textTheme.bodySmall : null,
);
final pending = store.isPending(attachment.ref)
? const Tooltip(
message: kImageWaitingToUpload,
child: Icon(Icons.cloud_upload_outlined, size: 18),
? const Icon(
Icons.cloud_upload_outlined,
size: 18,
semanticLabel: kImageWaitingToUpload,
)
: null;
final content = widget.compact
@ -104,15 +108,21 @@ class _FileEmbedState extends State<FileEmbed> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
],
);
// Spoken only: nothing paints this string.
final semanticLabel = 'Attached file ${attachment.name}, $detail';
return Semantics(
label: 'Attached file ${attachment.name}, $detail',
label: semanticLabel,
button: size != null,
child: Material(
color: theme.colorScheme.surfaceContainerHighest,

View File

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

View File

@ -6,10 +6,11 @@ library;
import 'dart:async';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/data/note.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_task_details.dart';
@ -61,53 +62,54 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
firstLine.isEmpty ? '(empty)' : firstLine,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
// User content; untitled shows nothing, not a placeholder.
if (firstLine.isNotEmpty)
Text(
firstLine,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
NoteSheetDetails(note: widget.note),
const SizedBox(height: 16),
_EnumChips<Status>(
label: 'Status',
GlyphChips<Status>(
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
values: Status.values,
selected: {_status},
labelOf: (s) => s.label,
onSelected: (s) {
glyphOf: statusGlyph,
onTap: (s) {
setState(() => _status = s);
unawaited(_persist());
},
),
const SizedBox(height: 12),
_EnumChips<Priority>(
label: 'Priority',
GlyphChips<Priority>(
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
values: Priority.values,
selected: {_priority},
labelOf: (p) => p.label,
onSelected: (p) {
glyphOf: (p) => priorityGlyph(context, p),
onTap: (p) {
setState(() => _priority = p);
unawaited(_persist());
},
),
NoteTaskDetails(note: widget.note),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.delete_outline),
label: const Text('Delete note'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
Align(
child: IconButton.outlined(
color: Theme.of(context).colorScheme.error,
icon: const Icon(
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';
/// 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.
class DateRangeField extends StatelessWidget {
/// Creates a labelled date-range control.
const DateRangeField({
required this.label,
required this.icon,
required this.semanticLabel,
required this.from,
required this.to,
required this.onChanged,
super.key,
});
/// Heading shown above the control (e.g. 'Created').
final String label;
/// Glyph shown in the control's border in place of a written heading.
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.
final DateTime? from;
@ -86,15 +96,16 @@ class DateRangeField extends StatelessWidget {
// A popup menu rather than DropdownButton: re-picking "Custom…" while a
// custom range is shown must still reopen the picker.
return PopupMenuButton<_Preset>(
tooltip: label,
// Empty, not the label: a tooltip is painted on long-press.
tooltip: '',
onSelected: (p) => _select(context, p),
itemBuilder: (_) => [
for (final p in _Preset.values)
PopupMenuItem(value: p, child: Text(p.label)),
PopupMenuItem(value: p, child: p.glyph()),
],
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, semanticLabel: semanticLabel),
isDense: true,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
@ -103,10 +114,9 @@ class DateRangeField extends StatelessWidget {
),
suffixIcon: const Icon(Icons.arrow_drop_down),
),
child: Text(
current == _Preset.custom ? _rangeLabel() : current.label,
style: theme.textTheme.bodyMedium,
),
child: current == _Preset.custom
? Text(_rangeLabel(), style: theme.textTheme.bodyMedium)
: current.glyph(),
),
);
}
@ -131,8 +141,26 @@ enum _Preset {
const _Preset(this.label, this.days);
/// Spoken name only; what is painted is [glyph].
final String label;
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];
}

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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_template.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_input.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()));
Future<void> _delete() async {
final confirmed = await confirmDestructive(
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
if (!await confirmDeleteNote(context)) return;
await widget.repository.delete(_note.id);
if (mounted) Navigator.of(context).pop();
}
@ -100,7 +95,8 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
final title = noteTitle(_note.text);
return Scaffold(
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: [
AttachButton(
onFiles: _attachFiles,
@ -114,8 +110,10 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
onPressed: _copy,
),
IconButton(
tooltip: 'Delete note',
icon: const Icon(Icons.delete_outline),
icon: const Icon(
Icons.delete_outline,
semanticLabel: 'Delete note',
),
onPressed: _delete,
),
],

View File

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

View File

@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note_template.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.
class NoteEditorChrome extends StatelessWidget {
@ -43,20 +44,20 @@ class NoteEditorChrome extends StatelessWidget {
DropdownButtonFormField<String>(
initialValue: template.id,
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,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
for (final t in NoteTemplate.all)
DropdownMenuItem(value: t.id, child: Text(t.label)),
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
],
onChanged: (id) {
if (id == null) return;
onTemplateSelected(
NoteTemplate.all.firstWhere((t) => t.id == id),
);
onTemplateSelected(NoteTemplate.all.firstWhere((t) => t.id == id));
},
),
const SizedBox(height: 8),
@ -67,8 +68,7 @@ class NoteEditorChrome extends StatelessWidget {
segments: [
const ButtonSegment(
value: NoteEditorMode.preview,
icon: Icon(Icons.visibility_outlined),
label: Text('View'),
icon: Icon(Icons.visibility_outlined, semanticLabel: 'View'),
),
// Guided is offered for any structured template; tapping it
// on an empty draft opens the wizard, and is blocked at
@ -76,13 +76,11 @@ class NoteEditorChrome extends StatelessWidget {
if (!template.isFreeform)
const ButtonSegment(
value: NoteEditorMode.guided,
icon: Icon(Icons.checklist),
label: Text('Guided'),
icon: Icon(Icons.checklist, semanticLabel: 'Guided'),
),
const ButtonSegment(
value: NoteEditorMode.raw,
icon: Icon(Icons.notes),
label: Text('Raw'),
icon: Icon(Icons.notes, semanticLabel: 'Raw'),
),
],
selected: {mode},

View File

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

View File

@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note_template.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.
class NoteEditorStepPage extends StatelessWidget {
@ -66,8 +67,7 @@ class NoteEditorStepPage extends StatelessWidget {
Align(
alignment: Alignment.centerLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Exit guided',
icon: const Icon(Icons.arrow_back, semanticLabel: 'Exit guided'),
onPressed: onExit,
),
),
@ -96,16 +96,14 @@ class NoteEditorStepPage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
section.isTitle ? 'title' : section.label,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
section.helper,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
// A glyph per section; its label (the note's `## heading`) and
// the helper sentence are spoken only.
Icon(
sectionIcon(section),
color: theme.colorScheme.primary,
semanticLabel: section.isTitle
? section.helper
: '${section.label}: ${section.helper}',
),
const SizedBox(height: 12),
],
@ -123,9 +121,10 @@ class NoteEditorStepPage extends StatelessWidget {
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
hintText: section.hint,
border: const OutlineInputBorder(),
// No hintText: a hint is painted text. The section glyph
// above already names the field.
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => onChanged(),
@ -140,16 +139,22 @@ class NoteEditorStepPage extends StatelessWidget {
if (index > 0)
TextButton(
onPressed: () => onGoToStep(index - 1),
child: const Text('Back'),
child: const Icon(
Icons.navigate_before,
semanticLabel: 'Back',
),
),
const Spacer(),
if (index < total - 1)
FilledButton(
onPressed: () => onGoToStep(index + 1),
child: const Text('Next'),
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
)
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_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].
class NoteEditorWizard extends StatefulWidget {
@ -48,7 +50,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final semanticLabel = 'Step ${_step + 1} of 2';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@ -56,11 +58,24 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
Row(
children: [
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Cancel',
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
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),
@ -74,14 +89,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
DropdownButtonFormField<Priority>(
initialValue: _priority,
decoration: const InputDecoration(
labelText: 'Priority',
prefixIcon: Icon(kPriorityHeadingIcon, semanticLabel: 'Priority'),
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
for (final p in Priority.values)
DropdownMenuItem(value: p, child: Text(p.label)),
DropdownMenuItem(value: p, child: priorityGlyph(context, p)),
],
onChanged: (p) {
if (p != null) setState(() => _priority = p);
@ -92,7 +107,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
alignment: Alignment.centerRight,
child: FilledButton(
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>(
initialValue: _template.id,
decoration: const InputDecoration(
labelText: 'Template',
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
for (final t in templates)
DropdownMenuItem(value: t.id, child: Text(t.label)),
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
],
onChanged: (id) {
if (id == null) return;
@ -124,12 +139,12 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
children: [
TextButton(
onPressed: () => setState(() => _step = 0),
child: const Text('Back'),
child: const Icon(Icons.arrow_back, semanticLabel: 'Back'),
),
const SizedBox(width: 8),
FilledButton(
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_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.
class NoteGridTile extends StatelessWidget {
@ -14,9 +33,13 @@ class NoteGridTile extends StatelessWidget {
required this.note,
required this.onTap,
required this.onActions,
this.selected = false,
super.key,
});
/// Whether the note is part of the multi-selection: a tint and a check.
final bool selected;
/// The note this tile displays.
final Note note;
@ -27,18 +50,7 @@ class NoteGridTile extends StatelessWidget {
final VoidCallback onActions;
/// Danger for high, the accent for medium, muted for low.
Color accentFor(ColorScheme scheme) => switch (note.priority) {
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,
};
Color accentFor(ColorScheme scheme) => priorityColor(note.priority, scheme);
@override
Widget build(BuildContext context) {
@ -49,7 +61,7 @@ class NoteGridTile extends StatelessWidget {
final closed =
note.status == Status.done || note.status == Status.abandoned;
return Material(
color: scheme.surfaceContainerHigh,
color: selected ? scheme.secondaryContainer : scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
@ -72,14 +84,30 @@ class NoteGridTile extends StatelessWidget {
children: [
// Expanded: a big text scale shortens the title, never overflows.
Expanded(
child: Text(
title.isEmpty ? '(empty)' : title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: closed ? scheme.onSurfaceVariant : null,
decoration: closed ? TextDecoration.lineThrough : null,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
title.isEmpty ? kEmptyTitle : title,
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?)
@ -91,25 +119,20 @@ class NoteGridTile extends StatelessWidget {
),
Row(
children: [
// Shape + the tile's accent border carry status/priority;
// the enum words survive only as spoken labels.
Icon(
_statusIcon(note.status),
statusIcon(note.status),
size: 14,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: AppSpacing.xs),
Flexible(
child: Text(
note.status.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: cue,
),
semanticLabel: note.status.label,
),
const Spacer(),
Text(
note.priority.label,
maxLines: 1,
style: cue?.copyWith(color: accentFor(scheme)),
Icon(
priorityIcon(note.priority),
size: 14,
color: accentFor(scheme),
semanticLabel: note.priority.label,
),
],
),

View File

@ -8,24 +8,30 @@ library;
import 'package:flutter/material.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
/// without duplication; [labelOf] maps a value to its display string.
/// without duplication; [glyphOf] maps a value to its glyph.
class MetaDropdown<T> extends StatelessWidget {
/// Creates a dropdown showing [value] out of [values].
const MetaDropdown({
required this.label,
required this.heading,
required this.semanticLabel,
required this.value,
required this.values,
required this.labelOf,
required this.glyphOf,
required this.onChanged,
super.key,
});
/// Label shown above the control.
final String label;
/// Glyph in the control's border in place of a written heading.
final IconData heading;
/// Spoken name of the control; never painted.
final String semanticLabel;
/// Currently selected value.
final T value;
@ -33,8 +39,8 @@ class MetaDropdown<T> extends StatelessWidget {
/// Every selectable value.
final List<T> values;
/// Maps a value to its display string.
final String Function(T) labelOf;
/// Maps a value to its glyph.
final Widget Function(T) glyphOf;
/// Called with the newly picked value.
final ValueChanged<T> onChanged;
@ -43,7 +49,7 @@ class MetaDropdown<T> extends StatelessWidget {
Widget build(BuildContext context) {
return InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(heading, semanticLabel: semanticLabel),
isDense: true,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@ -55,7 +61,7 @@ class MetaDropdown<T> extends StatelessWidget {
isExpanded: true,
items: [
for (final v in values)
DropdownMenuItem<T>(value: v, child: Text(labelOf(v))),
DropdownMenuItem<T>(value: v, child: glyphOf(v)),
],
onChanged: (v) {
if (v != null) onChanged(v);
@ -98,20 +104,22 @@ class NoteMetaRow extends StatelessWidget {
children: [
Expanded(
child: MetaDropdown<Priority>(
label: 'Priority',
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
value: priority,
values: Priority.values,
labelOf: (p) => p.label,
glyphOf: (p) => priorityGlyph(context, p),
onChanged: onPriorityChanged,
),
),
const SizedBox(width: 12),
Expanded(
child: MetaDropdown<Status>(
label: 'Status',
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
value: status,
values: Status.values,
labelOf: (s) => s.label,
glyphOf: statusGlyph,
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),
],
const SizedBox(height: 12),
Text(
'Edited ${_stamp(note.updatedAt)}'
' · Created ${_stamp(note.createdAt)}',
style: theme.bodySmall,
Wrap(
spacing: 12,
children: [
_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
Widget build(BuildContext context) {
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 = [
('Repo', note.repo),
('Done check', note.doneCheck),
('By', note.by),
('Evidence', note.evidence),
].where((row) => row.$2 != null);
(Icons.folder_outlined, 'Repo', note.repo),
(Icons.fact_check_outlined, 'Done check', note.doneCheck),
(Icons.person_outline, 'By', note.by),
(Icons.verified_outlined, 'Evidence', note.evidence),
].where((row) => row.$3 != null);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows) ...[
for (final (icon, label, value) in rows) ...[
const SizedBox(height: 12),
Text(label, style: theme.labelLarge),
const SizedBox(height: 4),
Text(value!, style: theme.bodyMedium),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
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_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.
class NoteTile extends StatelessWidget {
@ -16,9 +18,13 @@ class NoteTile extends StatelessWidget {
required this.note,
required this.onTap,
required this.onActions,
this.selected = false,
super.key,
});
/// Whether the row is part of the multi-selection: highlighted + a check.
final bool selected;
/// The note this row displays.
final Note note;
@ -33,23 +39,19 @@ class NoteTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
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(
selected: selected,
leading: selected
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
: null,
title: Text(
firstLine.isEmpty ? '(empty)' : firstLine,
firstLine.isEmpty ? kEmptyTitle : firstLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(meta),
subtitle: _meta(context),
trailing: IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Quick actions',
icon: const Icon(Icons.more_vert, semanticLabel: 'Quick actions'),
onPressed: onActions,
),
onTap: onTap,
@ -57,14 +59,28 @@ class NoteTile extends StatelessWidget {
);
}
/// Compact relative time like "2m ago" for the list subtitle.
String _relative(DateTime t) {
final d = DateTime.now().difference(t);
if (d.inMinutes < 1) return 'just now';
if (d.inHours < 1) return '${d.inMinutes}m ago';
if (d.inDays < 1) return '${d.inHours}h ago';
return '${d.inDays}d ago';
}
/// Status and priority glyphs, the edit age, and the repo (content).
/// Every note has a status and a priority, so both are always shown.
Widget _meta(BuildContext context) => Row(
children: [
statusGlyph(note.status, size: 16),
const SizedBox(width: 6),
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_repository.dart';
import 'package:todo/ui/enum_glyphs.dart';
import 'package:todo/ui/note_date_range_field.dart';
import 'package:todo/ui/notes_list_defaults.dart';
@ -66,7 +67,6 @@ class _FilterSheetState extends State<FilterSheet> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
@ -82,33 +82,38 @@ class _FilterSheetState extends State<FilterSheet> {
children: [
Row(
children: [
Text('Filters', style: theme.textTheme.titleLarge),
const Icon(Icons.filter_list, semanticLabel: 'Filters'),
const Spacer(),
TextButton(
onPressed: _clearAll,
child: const Text('Clear all'),
child: const Icon(Icons.clear, semanticLabel: 'Clear all'),
),
],
),
const SizedBox(height: 8),
_MultiChips<Status>(
label: 'Status',
GlyphChips<Status>(
multi: true,
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
values: Status.values,
selected: _statuses,
labelOf: (s) => s.label,
onToggle: (s) => _toggle(_statuses, s),
glyphOf: statusGlyph,
onTap: (s) => _toggle(_statuses, s),
),
const SizedBox(height: 12),
_MultiChips<Priority>(
label: 'Priority',
GlyphChips<Priority>(
multi: true,
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
values: Priority.values,
selected: _priorities,
labelOf: (p) => p.label,
onToggle: (p) => _toggle(_priorities, p),
glyphOf: (p) => priorityGlyph(context, p),
onTap: (p) => _toggle(_priorities, p),
),
const SizedBox(height: 12),
DateRangeField(
label: 'Created',
icon: kCreatedIcon,
semanticLabel: 'Created',
from: _createdFrom,
to: _createdTo,
onChanged: (from, to) => setState(() {
@ -118,7 +123,8 @@ class _FilterSheetState extends State<FilterSheet> {
),
const SizedBox(height: 12),
DateRangeField(
label: 'Last updated',
icon: kUpdatedIcon,
semanticLabel: 'Last updated',
from: _updatedFrom,
to: _updatedTo,
onChanged: (from, to) => setState(() {
@ -129,7 +135,7 @@ class _FilterSheetState extends State<FilterSheet> {
const SizedBox(height: 20),
FilledButton(
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.
///
/// 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.onOpen,
required this.onActions,
required this.isSelected,
super.key,
});
/// Whether the note with this id is in the multi-selection.
final bool Function(String id) isSelected;
/// Notes in display order.
final List<Note> notes;
@ -42,6 +46,7 @@ class NotesGrid extends StatelessWidget {
return NoteGridTile(
key: ValueKey(note.id),
note: note,
selected: isSelected(note.id),
onTap: () => onOpen(note),
onActions: () => onActions(note),
);

View File

@ -16,9 +16,13 @@ class NotesListBody extends StatelessWidget {
required this.notes,
required this.onOpen,
required this.onActions,
required this.isSelected,
super.key,
});
/// Whether the note with this id is in the multi-selection.
final bool Function(String id) isSelected;
/// Notes in display order.
final List<Note> notes;
@ -32,13 +36,19 @@ class NotesListBody extends StatelessWidget {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
? NotesGrid(notes: notes, onOpen: onOpen, onActions: onActions)
? NotesGrid(
notes: notes,
onOpen: onOpen,
onActions: onActions,
isSelected: isSelected,
)
: ListView.separated(
itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => NoteTile(
key: ValueKey(notes[i].id),
note: notes[i],
selected: isSelected(notes[i].id),
onTap: () => onOpen(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/note.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_list_body.dart';
import 'package:todo/ui/notes_list_defaults.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';
part 'notes_list_screen_select.dart';
/// Searchable, filterable, sortable list of stored/synced notes.
///
/// 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.
late Stream<List<Note>> _stream;
/// Multi-select; any change rebuilds the screen (tiles + bottom bar).
late final _selection = NoteSelection()..addListener(() => setState(() {}));
@override
void initState() {
super.initState();
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
_stream = _watch();
}
@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
_selection.dispose();
super.dispose();
}
/// Rebuilds the watch stream for the current sort + filter. Call only
/// from handlers that change those, never from [build].
void _applyState() {
setState(() {
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
});
setState(() => _stream = _watch());
}
/// 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) {
@ -108,29 +135,41 @@ class _NotesListScreenState extends State<NotesListScreen> {
final badgeCount = userFacetCount(_filter);
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),
title: const Icon(Icons.note, semanticLabel: 'Notes'),
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>(
tooltip: 'Sort',
icon: const Icon(Icons.sort),
// Empty, not omitted: null falls back to the painted "Show menu".
tooltip: '',
icon: const Icon(Icons.sort, semanticLabel: 'Sort'),
initialValue: _sort,
onSelected: _setSort,
itemBuilder: (_) => const [
PopupMenuItem(
value: NoteSort.modifiedDesc,
child: Text('Last updated'),
child: Icon(Icons.update, semanticLabel: 'Last updated'),
),
PopupMenuItem(
value: NoteSort.createdDesc,
child: Text('Newest created'),
child: Icon(Icons.post_add, semanticLabel: 'Newest created'),
),
PopupMenuItem(
value: NoteSort.alphabetical,
child: Text('Alphabetical'),
child: Icon(Icons.sort_by_alpha, semanticLabel: 'Alphabetical'),
),
PopupMenuItem(
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'),
offset: const Offset(-8, 4),
child: IconButton(
tooltip: 'Filter',
icon: const Icon(Icons.filter_list),
icon: const Icon(Icons.filter_list, semanticLabel: 'Filter'),
onPressed: _openFilters,
),
),
@ -161,15 +199,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
onChanged: _onSearchChanged,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search notes…',
prefixIcon: const Icon(Icons.search),
isDense: true,
border: const OutlineInputBorder(),
suffixIcon: _searchController.text.isEmpty
? null
: IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
icon: const Icon(
Icons.clear,
semanticLabel: 'Clear search',
),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
@ -186,27 +225,21 @@ class _NotesListScreenState extends State<NotesListScreen> {
final notes = snapshot.data ?? const <Note>[];
if (notes.isEmpty) {
return Center(
child: Text(
_filter.isEmpty
? 'No notes yet'
: 'No notes match these filters',
),
child: EmptyNotesCue(filtered: !_filter.isEmpty),
);
}
void open(Note n) =>
openNote(context, n, widget.repository, widget.appSettings);
void actions(Note n) =>
openNoteActions(context, n, widget.repository);
return NotesListBody(
notes: notes,
onOpen: open,
onActions: actions,
onOpen: (n) => _tap(n, notes),
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

@ -1,6 +1,8 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart'
show E2ePassphraseButton;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
@ -13,6 +15,7 @@ import 'package:todo/sync/backlog_export.dart';
import 'package:todo/sync/firebase_backend.dart';
import 'package:todo/sync/google_sign_in_backend.dart';
import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/sync/run_sync.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/cloud_images_settings.dart';
@ -105,11 +108,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// session is available, best-effort mirrors it so the choice follows the
/// user to their other devices.
///
/// The local write always lands — a signed-in-as-wrong-account device
/// throwing [FirebaseAuthError] out of `openFirebase()` must not take the
/// 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.
/// The local write always lands: a [FirebaseAuthError] from opening the
/// client (wrong account) is caught on its own, before the write.
Future<void> _setAdvancedMode(bool value) async {
final analytics = widget.analytics;
if (analytics != null) {
@ -167,6 +167,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
await widget.repository.importNotes(notes);
}
/// Retries the sync after a passphrase is entered; the lock tile follows
/// [e2eLocked], which the run updates.
Future<void> _syncNow() async {
try {
await runSync(
widget.repository,
widget.initial,
httpClient: widget.httpClient,
firebaseFactory: widget.firebaseFactory,
stateStore: widget.stateStore,
);
} on Exception {
// The capture screen's status line reports sync failures.
}
}
Future<void> _openSyncSettings() async {
await Navigator.of(context).push<void>(
appRoute(
@ -206,7 +222,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
appBar: AppBar(
title: const Icon(Icons.settings, semanticLabel: 'Settings'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -214,33 +232,49 @@ class _SettingsScreenState extends State<SettingsScreen> {
valueListenable: widget.appSettings,
builder: (context, settings, _) => SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable advanced'),
subtitle: const Text(
'Priority/status, templates, view modes, and sync details '
'in the capture screen',
),
secondary: const Icon(Icons.tune, semanticLabel: _advanced),
value: settings.advancedMode,
onChanged: (value) => unawaited(_setAdvancedMode(value)),
),
),
const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Sync settings'),
subtitle: const Text('Firebase sync and notes backup'),
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openSyncSettings()),
_link(
const Icon(Icons.sync, semanticLabel: 'Sync settings: Firebase'),
_openSyncSettings,
),
ValueListenableBuilder<bool>(
valueListenable: e2eLocked,
builder: (context, locked, _) => ListTile(
contentPadding: EdgeInsets.zero,
leading: E2ePassphraseButton(
locked: locked,
// A new passphrase is only proven by a sync that unlocks.
onSaved: () => unawaited(_syncNow()),
),
),
),
CloudImagesTile(httpClient: widget.httpClient),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Advanced sync (GitHub)'),
subtitle: const Text('Cutover mirror — not recommended'),
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openGitHubMirror()),
_link(
Icon(
Icons.cloud_sync,
color: Theme.of(context).colorScheme.outline,
semanticLabel: 'Advanced sync (GitHub): not recommended',
),
_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>`.
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 |
|---|---|
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |

View File

@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: android_file_picker
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.5"
archive:
dependency: transitive
description:
@ -109,11 +109,20 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.12.1"
resolved-ref: "6641d85f0550ca60433fc8e0d13a3dafdbb664d6"
ref: "crdt_sync_dart-v0.14.0"
resolved-ref: "84343aec2dd07c38cba40690c3b5f2c84ae9cdf6"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.12.1"
version: "0.14.0"
crdt_sync_flutter:
dependency: "direct main"
description:
path: crdt_sync_flutter
ref: "crdt_sync_flutter-v0.8.0"
resolved-ref: e5990c9c15761934110f955529bc1d68479b06ff
url: "https://github.com/kuhyx/utils"
source: git
version: "0.8.0"
cross_file:
dependency: transitive
description:
@ -130,6 +139,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.8"
cryptography:
dependency: transitive
description:
name: cryptography
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
url: "https://pub.dev"
source: hosted
version: "2.9.0"
cupertino_icons:
dependency: "direct main"
description:
@ -142,11 +159,11 @@ packages:
dependency: transitive
description:
path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.1"
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
ref: "data_transfer_flutter-v0.2.2"
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
url: "https://github.com/kuhyx/utils"
source: git
version: "0.2.1"
version: "0.2.2"
dbus:
dependency: transitive
description:
@ -208,10 +225,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86"
url: "https://pub.dev"
source: hosted
version: "13.3.2"
version: "13.3.3"
file_picker_darwin:
dependency: transitive
description:
@ -224,10 +241,10 @@ packages:
dependency: transitive
description:
name: file_picker_linux
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.0.4"
file_picker_platform_interface:
dependency: transitive
description:
@ -240,10 +257,10 @@ packages:
dependency: transitive
description:
name: file_picker_web
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c
sha256: "50749a6da2984fc29609e102ad4d80fd1d07e6895150f8e4922086167d9d45d8"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
version: "4.2.1"
file_selector:
dependency: "direct main"
description:
@ -399,11 +416,11 @@ packages:
dependency: "direct main"
description:
path: github_device_auth
ref: "github_device_auth-v0.3.2"
resolved-ref: "4ccaba1bb3e495ef72d70c6a7e7d7ec98d755792"
ref: "github_device_auth-v0.4.0"
resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.3.2"
version: "0.4.0"
glob:
dependency: transitive
description:
@ -449,10 +466,10 @@ packages:
dependency: "direct main"
description:
name: idb_shim
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b"
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
url: "https://pub.dev"
source: hosted
version: "2.11.2"
version: "2.11.3"
image:
dependency: "direct main"
description:
@ -833,10 +850,10 @@ packages:
dependency: transitive
description:
name: sembast
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a"
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
url: "https://pub.dev"
source: hosted
version: "3.10.0"
version: "3.10.1"
share_plus:
dependency: "direct main"
description:
@ -934,10 +951,10 @@ packages:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4"
sha256: "9aad7a63bb8de6fe4c1e5367e8dcd8dfc89d491f44a526edf8e58a5a1fb1feed"
url: "https://pub.dev"
source: hosted
version: "2.4.5"
version: "2.4.7"
sqflite_common_ffi_web:
dependency: transitive
description:
@ -1006,11 +1023,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.6.2"
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488"
ref: "sync_settings_ui-v0.7.2"
resolved-ref: "4567e7ca3042e683befdf6697ace1c8be9c829fc"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.6.2"
version: "0.7.2"
synchronized:
dependency: transitive
description:
@ -1159,10 +1176,10 @@ packages:
dependency: transitive
description:
name: windows_file_picker
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "2.0.2"
xdg_directories:
dependency: transitive
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
# 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.
version: 1.0.63+1
version: 1.0.86+1
environment:
sdk: ^3.12.2
@ -33,8 +33,13 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.12.1
ref: crdt_sync_dart-v0.14.0
path: crdt_sync_dart
crdt_sync_flutter:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_flutter-v0.8.0
path: crdt_sync_flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
@ -47,7 +52,7 @@ dependencies:
url: https://github.com/kuhyx/utils
ref: design_system-v0.3.1
path: design_system
file_picker: 13.3.2
file_picker: 13.3.3
file_selector: 1.1.0
flutter:
sdk: flutter
@ -59,7 +64,7 @@ dependencies:
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.3.2
ref: github_device_auth-v0.4.0
path: github_device_auth
# 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/.
@ -69,7 +74,7 @@ dependencies:
ref: google_account_token-v0.1.0
path: google_account_token
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
# phone and the desktop wrapper share one code path.
image: 4.12.0
@ -85,7 +90,7 @@ dependencies:
path_provider: 2.1.6
share_plus: 13.3.1
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
# migration in NoteRepository.open; remove once all devices are migrated.
sqlite_crdt: 3.0.4
@ -97,7 +102,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.6.2
ref: sync_settings_ui-v0.7.2
path: sync_settings_ui
url_launcher: 6.3.3
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

@ -47,8 +47,9 @@ void main() {
expect(pubspec, contains('google_account_token-v0.1.0'));
expect(pubspec, isNot(contains('google_sign_in')));
expect(
File('android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt')
.existsSync(),
File(
'android/app/src/main/kotlin/dev/kuhy/todo/AccountTokenChannel.kt',
).existsSync(),
isFalse,
);
expect(File('lib/sync/account_token.dart').existsSync(), isFalse);

View File

@ -95,10 +95,7 @@ void main() {
const service = AnalyticsService(nodeId: 'device-a');
for (var i = 0; i < AnalyticsService.maxBufferedEvents + 5; i++) {
await service.logEvent(
AnalyticsEvent(
name: 'event_$i',
timestamp: DateTime.now(),
),
AnalyticsEvent(name: 'event_$i', timestamp: DateTime.now()),
);
}

View File

@ -135,42 +135,39 @@ void main() {
expect((remaining.single as Map)['name'], 'mid_flush_event');
});
test(
'restores the claimed batch ahead of events logged mid-flush on '
'failure',
() async {
// The HTTP handler runs mid-`putFileText`, strictly after flush()
// has already claimed (cleared) the buffer, so writing here is a
// genuine race rather than something that could instead land before
// or after `flush()` depending on scheduling.
final client = _fakeClient((_) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'analytics.buffer',
jsonEncode([
AnalyticsEvent(
name: 'mid_flush_event',
timestamp: DateTime(2026),
).toJson(),
]),
);
return http.Response('error', 500);
});
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)),
);
await service.flush(client);
test('restores the claimed batch ahead of events logged mid-flush on '
'failure', () async {
// The HTTP handler runs mid-`putFileText`, strictly after flush()
// has already claimed (cleared) the buffer, so writing here is a
// genuine race rather than something that could instead land before
// or after `flush()` depending on scheduling.
final client = _fakeClient((_) async {
final prefs = await SharedPreferences.getInstance();
final remaining =
jsonDecode(prefs.getString('analytics.buffer')!) as List;
expect(remaining, hasLength(2));
expect((remaining[0] as Map)['name'], 'app_open');
expect((remaining[1] as Map)['name'], 'mid_flush_event');
},
);
await prefs.setString(
'analytics.buffer',
jsonEncode([
AnalyticsEvent(
name: 'mid_flush_event',
timestamp: DateTime(2026),
).toJson(),
]),
);
return http.Response('error', 500);
});
const service = AnalyticsService(nodeId: 'device-a');
await service.logEvent(
AnalyticsEvent(name: 'app_open', timestamp: DateTime(2026)),
);
await service.flush(client);
final prefs = await SharedPreferences.getInstance();
final remaining =
jsonDecode(prefs.getString('analytics.buffer')!) as List;
expect(remaining, hasLength(2));
expect((remaining[0] as Map)['name'], 'app_open');
expect((remaining[1] as Map)['name'], 'mid_flush_event');
});
test(
'a RemoteSyncError from an expired session restores the buffer',

View File

@ -91,9 +91,7 @@ void main() {
});
test('swallows a push failure and keeps the local write', () async {
final client = _fakeClient(
(_) async => http.Response('error', 500),
);
final client = _fakeClient((_) async => http.Response('error', 500));
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(
@ -107,22 +105,19 @@ void main() {
expect(reloaded.advancedMode, isTrue);
});
test(
'swallows a FirebaseAuthError (expired session) and keeps the local '
'write',
() async {
const initial = AppSettings(advancedMode: false);
test('swallows a FirebaseAuthError (expired session) and keeps the local '
'write', () async {
const initial = AppSettings(advancedMode: false);
final updated = await initial.withAdvancedMode(
value: true,
client: _authFailingClient(),
);
final updated = await initial.withAdvancedMode(
value: true,
client: _authFailingClient(),
);
expect(updated.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
},
);
expect(updated.advancedMode, isTrue);
final reloaded = await AppSettings.load();
expect(reloaded.advancedMode, isTrue);
});
});
group('reconcileWithRemote', () {
@ -220,9 +215,7 @@ void main() {
() async {
const initial = AppSettings(advancedMode: false);
final result = await initial.reconcileWithRemote(
_authFailingClient(),
);
final result = await initial.reconcileWithRemote(_authFailingClient());
expect(identical(result, initial), isTrue);
},

View File

@ -20,60 +20,59 @@ import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart';
import 'e2e_github_header.dart';
import 'text_free_guard.dart';
void main() {
testWidgets(
'auto-sync adopts a newer advancedMode value from Firebase',
(tester) async {
final remoteTime = DateTime(2026, 6);
final firebase = FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
testWidgets('auto-sync adopts a newer advancedMode value from Firebase', (
tester,
) async {
final remoteTime = DateTime(2026, 6);
final firebase = FirebaseRestClient(
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
auth: FirebaseTokenProvider(
apiKey: 'AIzaKey',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id',
refreshToken: 'refresh',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
),
httpClient: MockClient((request) async {
if (request.url.path == '/settings/advancedMode.json') {
return http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
);
}
if (request.method == 'PUT') return http.Response(request.body, 200);
return http.Response('null', 200);
}),
);
final appSettings = ValueNotifier(
AppSettings(
advancedMode: false,
// Older than remoteTime, and non-null, so the reconciliation
// guard's `currentAt != null && !reconciledAt.isAfter(currentAt)`
// check actually evaluates both operands.
advancedModeUpdatedAt: remoteTime.subtract(
const Duration(days: 1),
),
),
);
),
httpClient: e2eMock((request) async {
if (request.url.path == '/settings/advancedMode.json') {
return http.Response(
'{"value": "true", '
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
200,
);
}
if (request.method == 'PUT') return http.Response(request.body, 200);
return http.Response('null', 200);
}),
);
final appSettings = ValueNotifier(
AppSettings(
advancedMode: false,
// Older than remoteTime, and non-null, so the reconciliation
// guard's `currentAt != null && !reconciledAt.isAfter(currentAt)`
// check actually evaluates both operands.
advancedModeUpdatedAt: remoteTime.subtract(const Duration(days: 1)),
),
);
await pumpCapture(
tester,
prefs: configuredPrefs,
firebaseFactory: () async => firebase,
appSettings: appSettings,
);
await tester.pump(); // settings load → auto-sync (pull) …
await tester.pump(); // … then push, then reconcile
await pumpCapture(
tester,
prefs: configuredPrefs,
firebaseFactory: () async => firebase,
appSettings: appSettings,
);
await tester.pump(); // settings load → auto-sync (pull) …
await tester.pump(); // … then push, then reconcile
expect(appSettings.value.advancedMode, isTrue);
},
);
expect(appSettings.value.advancedMode, isTrue);
});
testWidgets('logs app_open when analytics is injected', (tester) async {
const analytics = AnalyticsService(nodeId: 'device-a');
@ -140,7 +139,7 @@ void main() {
// Capture must never be interrupted by a snackbar…
expect(find.byType(SnackBar), findsNothing);
// …but the failure is no longer swallowed: the status line shows it.
expect(find.textContaining('Sync failed at'), findsOneWidget);
expect(find.textContaining('offline'), findsOneWidget);
expect(spoken('Sync failed at'), 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 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() {
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.
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Guided'), findsOneWidget);
expect(find.bySemanticsLabel('Guided'), findsOneWidget);
expect(find.text('0'), findsOneWidget);
// Tapping Guided on the empty draft opens the priority+template wizard
// rather than jumping straight into the stepper.
await tester.tap(find.text('Guided'));
await tester.tap(find.bySemanticsLabel('Guided'));
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.tap(find.text('Start'));
await tester.tap(find.bySemanticsLabel('Start'));
await tester.pump();
// Now on step 1 of the full-screen step page.
@ -49,13 +50,13 @@ void main() {
find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'),
findsOneWidget,
);
expect(find.textContaining('imperative'), findsOneWidget); // title helper
expect(spoken('imperative'), findsOneWidget); // title helper
});
testWidgets('saving the untouched template creates no note', (tester) async {
final repo = await pumpCapture(tester);
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump();
expect(await repo.listNotes(), isEmpty);
@ -83,7 +84,7 @@ void main() {
await tester.enterText(find.byType(TextField).first, 'A real idea');
await tester.pump();
await tester.tap(find.byTooltip('New note'));
await tester.tap(find.bySemanticsLabel('New note'));
await tester.pump();
expect(await repo.listNotes(), hasLength(1));
@ -98,15 +99,16 @@ void main() {
) async {
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();
await tester.pump(const Duration(milliseconds: 500)); // snack fades in
// The behaviour that matters is the routing, not the wording: an
// unconfigured device is sent to settings to connect a backend. Asserting
// on the snackbar text tied this to one phrasing, and the snackbar is
// 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', (
@ -114,11 +116,11 @@ void main() {
) async {
await pumpCapture(tester);
await tester.tap(find.byTooltip('Notes'));
await tester.tap(find.bySemanticsLabel('Notes'));
await tester.pump();
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', (
@ -134,7 +136,7 @@ void main() {
);
await tester.pump();
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();
expect((await repo.listNotes()).single.priority, Priority.high);
@ -153,7 +155,7 @@ void main() {
);
await tester.pump();
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();
expect((await repo.listNotes()).single.status, Status.inProgress);

View File

@ -28,8 +28,10 @@ import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/capture_screen.dart';
import 'package:todo/ui/github_mirror_screen.dart';
import 'package:todo/ui/settings_screen.dart';
import 'package:todo/ui/text_free_localizations.dart';
import 'fake_note_repository.dart';
import 'e2e_github_header.dart';
import 'fake_secure_storage.dart';
// A real CRDT DB schedules sqflite timers that never drain under the
@ -73,6 +75,8 @@ Future<FakeNoteRepository> pumpCapture(
debounce: Duration.zero,
);
final app = MaterialApp(
// As in main.dart: Material's own button tooltips are blanked.
localizationsDelegates: kTextFreeLocalizations,
home: CaptureScreen(
repository: repo,
appSettings:
@ -105,7 +109,7 @@ const configuredPrefs = {
// A MockClient that records request methods and answers the sync flow:
// repo-exists GET (not /contents/) → 200, the (empty) log listing → 404,
// and the device's own PUT → 200.
MockClient recordingMock(List<String> methods) => MockClient((req) async {
MockClient recordingMock(List<String> methods) => e2eMock((req) async {
methods.add(req.method);
if (req.method == 'PUT') return http.Response('{}', 200);
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);

View File

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

View File

@ -17,9 +17,11 @@ import 'package:todo/ui/github_mirror_screen.dart';
import 'package:todo/ui/settings_screen.dart';
import 'fake_note_repository.dart';
import 'e2e_github_header.dart';
import 'fake_secure_storage.dart';
import 'capture_screen_harness.dart';
import 'text_free_guard.dart';
void main() {
testWidgets('Sync with a configured token runs the sync service', (
@ -28,25 +30,24 @@ void main() {
// Empty remote directory → the service has nothing to merge and pushes
// this device's own log (PUT). The bare repo GET (not /contents/) returns
// 200 so crdt_sync's client treats the 404 dir as "empty", not missing.
final mock = MockClient((req) async {
final mock = e2eMock((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
return http.Response('', 404);
});
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(); // service runs, snackbar scheduled
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 --
// the status line must say so rather than reading identically to a
// Firebase-connected sync.
expect(
find.textContaining(
'Synced: GitHub-only (Firebase not connected) — merged 0 device',
),
spoken('Synced: GitHub-only (Firebase not connected) — merged 0 device'),
findsOneWidget,
);
});
@ -55,24 +56,25 @@ void main() {
final mock = MockClient((_) async => throw Exception('offline'));
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(const Duration(milliseconds: 500)); // snack fades in
// 'Sync failed:' is the snackbar; the status line says 'Sync failed at'.
expect(find.textContaining('Sync failed:'), findsOneWidget);
expect(find.textContaining('Sync failed at'), findsOneWidget);
expect(spoken('Sync failed:'), findsOneWidget);
expect(spoken('Sync failed at'), findsOneWidget);
});
testWidgets('manual sync reports peer files it could not decode', (
tester,
) async {
// One peer file exists but holds garbage → merged 0, skipped 1.
final mock = MockClient((req) async {
final mock = e2eMock((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
if (req.url.path.endsWith('/todo-sync/notes')) {
if (req.url.path.endsWith('/todo-sync/notes-e2e')) {
return http.Response('[{"name": "bad.json"}]', 200);
}
if (req.url.path.endsWith('/bad.json')) {
@ -85,13 +87,14 @@ void main() {
});
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(const Duration(milliseconds: 500)); // snack fades in
expect(
find.textContaining('skipped 1 unreadable file(s)'),
spoken('skipped 1 unreadable file(s)'),
findsWidgets, // snackbar and status line both call it out
);
});
@ -101,7 +104,7 @@ void main() {
) async {
await pumpCapture(tester);
await tester.tap(find.byTooltip('Settings'));
await tester.tap(find.bySemanticsLabel('Settings'));
await tester.pumpAndSettle(); // route transition
expect(find.byType(SettingsScreen), findsOneWidget); // settings is up
@ -112,25 +115,25 @@ void main() {
// 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
// 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
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
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
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:
// repo-exists GET (not /contents/) → 200, the (empty) log listing → 404,
// and the device's own PUT → 200.
MockClient recordingMock(List<String> methods) => MockClient((req) async {
MockClient recordingMock(List<String> methods) => e2eMock((req) async {
methods.add(req.method);
if (req.method == 'PUT') return http.Response('{}', 200);
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
@ -148,42 +151,36 @@ void main() {
await tester.pump();
// The editor's own template/mode chrome is gone.
expect(find.text('Guided'), findsNothing);
expect(find.text('Template'), findsNothing);
expect(find.bySemanticsLabel('Guided'), findsNothing);
expect(find.bySemanticsLabel('Template'), findsNothing);
// The capture screen's priority/status row is gone.
expect(find.text('Priority'), findsNothing);
expect(find.text('Status'), findsNothing);
expect(find.bySemanticsLabel('Priority'), findsNothing);
expect(find.bySemanticsLabel('Status'), findsNothing);
// 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);
// The note still persisted normally underneath the simplified UI.
expect(find.text('A plain idea'), findsOneWidget);
},
);
testWidgets(
'draft text survives an advancedMode toggle mid-typing',
(tester) async {
final appSettings = ValueNotifier(
const AppSettings(advancedMode: true),
);
await pumpCapture(tester, appSettings: appSettings);
testWidgets('draft text survives an advancedMode toggle mid-typing', (
tester,
) async {
final appSettings = ValueNotifier(const AppSettings(advancedMode: true));
await pumpCapture(tester, appSettings: appSettings);
await tester.enterText(
find.byType(TextField).first,
'my important idea',
);
await tester.pump();
await tester.enterText(find.byType(TextField).first, 'my important idea');
await tester.pump();
// NoteForm's metadata row is conditionally present, so toggling
// advancedMode changes the editor's position beneath it — a key one
// level too shallow doesn't survive that reposition and Flutter
// remounts the editor with an empty draft, losing the typing.
appSettings.value = const AppSettings(advancedMode: false);
await tester.pump();
// NoteForm's metadata row is conditionally present, so toggling
// advancedMode changes the editor's position beneath it — a key one
// level too shallow doesn't survive that reposition and Flutter
// remounts the editor with an empty draft, losing the typing.
appSettings.value = const AppSettings(advancedMode: false);
await tester.pump();
final field = tester.widget<TextField>(find.byType(TextField).first);
expect(field.controller?.text, contains('my important idea'));
},
);
final field = tester.widget<TextField>(find.byType(TextField).first);
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

@ -19,6 +19,9 @@ class CliHarness {
writeSeedCredentials(
tmp,
).copySync('${home.path}/.config/todo/firebase_auth.json');
File(
'${tmp.path}/e2e_passphrase',
).copySync('${home.path}/.config/todo/e2e_passphrase');
env = {'HOME': home.path, 'XDG_DATA_HOME': '${tmp.path}/xdg'};
}
@ -32,7 +35,11 @@ class CliHarness {
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();
err.clear();
return runCli(
@ -40,7 +47,7 @@ class CliHarness {
env: env,
out: out,
err: err,
readStdin: () async => '',
readStdin: () async => stdin,
open:
opener ?? (d, {required seedCredentials}) => open(d, seedCredentials),
);
@ -60,10 +67,11 @@ class CliHarness {
return out.toString().trim();
}
/// Publishes [notes] as another device's log.
void seedPeer(List<Note> notes) {
db.files['todo-sync/notes/peer~2Ejson'] = logToJson({
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'),
/// Publishes [notes] as device [node]'s log.
void seedPeer(List<Note> notes, {String node = 'peer'}) {
resetE2eCopyMemo();
db.files['todo-sync/notes/$node~2Ejson'] = logToJson({
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, node),
});
}
}

View File

@ -22,6 +22,9 @@ void main() {
writeSeedCredentials(
tmp,
).copySync('${home.path}/.config/todo/firebase_auth.json');
File(
'${tmp.path}/e2e_passphrase',
).copySync('${home.path}/.config/todo/e2e_passphrase');
env = {'HOME': home.path, 'XDG_DATA_HOME': '${tmp.path}/xdg'};
out = StringBuffer();
err = StringBuffer();
@ -86,7 +89,10 @@ void main() {
test('pushes an ordinary CRDT log and prints the new id', () async {
final id = await add('a', 'Title\n\nbody', ['--priority', 'high']);
expect(id, matches(RegExp(r'^[0-9a-f-]{36}$')));
expect(db.files.keys.where((k) => k.contains('notes/')), hasLength(1));
expect(
db.files.keys.where((k) => k.contains('notes-e2e/')),
hasLength(1),
);
expect(err.toString(), isEmpty);
});
@ -121,7 +127,7 @@ void main() {
expect(await run(['--list', '--data-dir', dir('b')]), 0);
expect(
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);

View File

@ -117,8 +117,8 @@ void main() {
final first = await session.sync(push: true);
expect(first.pushed, isTrue);
final node = session.repository.nodeId;
expect(db.files.keys, contains('todo-sync/notes/$node~2Ejson'));
expect(db.files.keys, contains('todo-sync/revs/$node'));
expect(db.files.keys, contains('todo-sync/notes-e2e/$node~2Ejson'));
expect(db.files.keys, contains('todo-sync/revs-e2e/$node'));
expect(
File('${tmp.path}/push/${CliSession.syncStateFileName}').existsSync(),
isTrue,

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(
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(h.out.toString(), isEmpty);

View File

@ -8,6 +8,7 @@ library;
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart' show resetE2eCopyMemo;
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
@ -64,6 +65,10 @@ class FakeRtdb {
/// Writes a Firebase session that does not expire during a test run.
File writeSeedCredentials(Directory dir, {String name = 'seed.json'}) {
// A fresh fake database: the one-time copy must run again for it.
resetE2eCopyMemo();
// The CLI reads its crdt-sync passphrase from beside the seed file.
File('${dir.path}/e2e_passphrase').writeAsStringSync('test-passphrase');
return File('${dir.path}/$name')..writeAsStringSync(
jsonEncode({
'id_token': 'seed-id-token',

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

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

@ -0,0 +1,35 @@
/// A GitHub stand-in that persists the end-to-end encryption header, which
/// the sync service writes and re-reads; every other request goes to [inner].
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Wraps [inner] so `…/e2e` PUTs are stored and served back on GET.
MockClient e2eMock(Future<http.Response> Function(http.Request) inner) {
String? header;
return MockClient((request) async {
if (request.url.path.endsWith('/e2e.json')) {
// Firebase REST: the value is a JSON-encoded string.
if (request.method == 'PUT') {
header = jsonDecode(request.body) as String;
return http.Response(request.body, 200);
}
return http.Response(jsonEncode(header), 200);
}
if (!request.url.path.endsWith('/e2e')) return inner(request);
if (request.method == 'PUT') {
final body = jsonDecode(request.body) as Map<String, dynamic>;
header = utf8.decode(base64.decode(body['content'] as String));
return http.Response(jsonEncode({'content': {}, 'sha': 'h'}), 200);
}
final text = header;
if (text == null) return http.Response('', 404);
return http.Response(
jsonEncode({'content': base64.encode(utf8.encode(text)), 'sha': 'h'}),
200,
);
});
}

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