Compare commits

..

No commits in common. "main" and "v1.0.51-build.117" have entirely different histories.

185 changed files with 1629 additions and 7665 deletions

2
.fvmrc
View File

@ -1,3 +1,3 @@
{
"flutter": "3.47.9"
"flutter": "3.47.7"
}

View File

@ -47,7 +47,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.9
flutter-version: 3.47.7
channel: stable
cache: true

View File

@ -73,30 +73,6 @@ repos:
language: system
files: \.(md|markdown)$
- id: todo-closer
name: close Todo trailer tasks
entry: scripts/todo_hooks.sh closer
language: system
stages: [post-commit]
always_run: true
pass_filenames: false
- id: todo-import-pr-merge
name: import PR Todo sections (pull)
entry: scripts/todo_hooks.sh import-pr post-merge
language: system
stages: [post-merge]
always_run: true
pass_filenames: false
- id: todo-import-pr-rewrite
name: import PR Todo sections (pull --rebase)
entry: scripts/todo_hooks.sh import-pr post-rewrite
language: system
stages: [post-rewrite]
always_run: true
pass_filenames: false
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
# so the question is mechanically answerable at all.
#

View File

@ -50,7 +50,6 @@ 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
@ -67,7 +66,7 @@ Three layers under `lib/`, each single-purpose:
`by`, `evidence`, written only when set, so an idea's record stays
v3-identical: `note_repository_sync_compat_test.dart`). The old sqlite
schema (v3) survives only in `legacy_sqlite_migration.dart`. Also defines
`NoteFilter` (query / priorities / statuses / repo / date ranges,
`NoteFilter` (query / priorities / statuses / sources / date ranges,
AND-combined), `NoteSort`, `watchNotes`/`watchCount` streams, and
`importNotes` (safe newer-wins merge by id).
- `sync/` — GitHub-as-storage sync.
@ -86,7 +85,7 @@ Three layers under `lib/`, each single-purpose:
- `capture_screen.dart`: landing screen; always-focused text box pre-filled
with the structured template; lazy note creation on first keystroke.
- `notes_list_screen.dart`: list + search + sort + filter sheet + per-note
sheet. One open note per repo (`data/repo_note.dart`). **Default view hides Done/Abandoned and shows no filter badge**
sheet. **Default view hides Done/Abandoned and shows no filter badge**
(looks unfiltered). Under 600dp wide (`kNotesGridMaxWidth`) the notes are
a 2-column `NotesGrid` of compact `NoteGridTile`s (title + priority/status
cues; body and dates are in the long-press sheet). The tile height is

View File

@ -0,0 +1,27 @@
# Fix flaky image_store_io_test "without picker copies everything is copied"
## what
`test/attachments/image_store_io_test.dart` "attachFile without picker copies
everything is copied" fails about 1 run in 4 under parallel load with
`PathNotFoundException: Cannot retrieve length of file` from
`ImageCacheStore._run` (`lib/images/image_cache_store.dart:135`). The test
returns while `IoImageStore.attachFile` still has its fire-and-forget
`unawaited(_sync(const {}))` running; teardown deletes the temp root and the
upload pass calls `file.length()` on a file that `existsSync()` just saw.
Predates the AccountManager branch (no file in lib/images, lib/attachments or
test/attachments was touched). Seen once as 591/592 and once with
`--reporter expanded` at 635/636.
## done
`for i in $(seq 20); do flutter test test/attachments/image_store_io_test.dart --concurrency=4 || exit 1; done`
exits 0, and the production path tolerates a file vanishing mid-pass (skip it
instead of throwing).
## verify
Same loop on this repo; plus the full suite ends with "All tests passed!".
## read first
- lib/attachments/image_store_io.dart (`attachFile`, `_sync`)
- lib/images/image_cache_store.dart (`_run`)
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,83 @@
package dev.kuhy.todo
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.AccountManagerFuture
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/** System account chooser, then an ID token from AccountManager. No browser. */
class AccountTokenChannel(private val activity: Activity) :
MethodChannel.MethodCallHandler {
private var pending: MethodChannel.Result? = null
private var audience: String = ""
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method != "idToken") {
result.notImplemented()
return
}
if (pending != null) {
result.error("busy", "sign-in already running", null)
return
}
audience = call.argument<String>("serverClientId") ?: ""
pending = result
val chooser = AccountManager.newChooseAccountIntent(
null, null, arrayOf("com.google"), null, null, null, null,
)
activity.startActivityForResult(chooser, REQUEST_CHOOSE)
}
fun onActivityResult(request: Int, code: Int, data: Intent?): Boolean {
if (request != REQUEST_CHOOSE) return false
val name = data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
val type = data?.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE)
if (code != Activity.RESULT_OK || name == null || type == null) {
finish { it.success(null) }
} else {
fetchToken(Account(name, type))
}
return true
}
private fun fetchToken(account: Account) {
val manager = AccountManager.get(activity)
val type = "audience:server:client_id:$audience"
// Activity argument: the framework launches any consent screen itself.
manager.getAuthToken(account, type, Bundle(), activity, { future ->
deliver(future)
}, Handler(Looper.getMainLooper()))
}
private fun deliver(future: AccountManagerFuture<Bundle>) {
try {
val bundle = future.result
val token = bundle.getString(AccountManager.KEY_AUTHTOKEN)
if (token != null) {
finish { it.success(token) }
} else {
finish { it.error("no_token", "keys: ${bundle.keySet()}", null) }
}
} catch (e: Exception) {
finish { it.error("auth_failed", e.toString(), null) }
}
}
private fun finish(block: (MethodChannel.Result) -> Unit) {
val result = pending ?: return
pending = null
block(result)
}
companion object {
const val CHANNEL = "dev.kuhy.todo/account_token"
private const val REQUEST_CHOOSE = 7421
}
}

View File

@ -1,5 +1,27 @@
package dev.kuhy.todo
import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private var tokens: AccountTokenChannel? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val channel = AccountTokenChannel(this)
tokens = channel
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
AccountTokenChannel.CHANNEL,
).setMethodCallHandler(channel)
}
@Deprecated("Deprecated in Java")
override fun onActivityResult(request: Int, code: Int, data: Intent?) {
if (tokens?.onActivityResult(request, code, data) == true) return
@Suppress("DEPRECATION")
super.onActivityResult(request, code, data)
}
}

View File

@ -1,7 +1,4 @@
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
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.9.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-all.zip

View File

@ -1,16 +0,0 @@
// One-time: one open note per repo, holding all of that repo's todos.
//
// dart run bin/merge_repo_notes.dart # dry run, prints the map
// dart run bin/merge_repo_notes.dart --apply # does it and pushes
import 'dart:io';
import 'package:todo/cli/merge_repo_notes.dart';
Future<void> main(List<String> argv) async {
exitCode = await runMergeRepoNotes(
argv,
env: Platform.environment,
out: stdout,
err: stderr,
);
}

View File

@ -4,12 +4,49 @@
/// 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].
@ -20,8 +57,6 @@ class CliArgs {
this.priority = Priority.medium,
this.status,
this.dataDir,
this.newNote = false,
this.dryRun = false,
this.opts = (
repo: null,
query: null,
@ -44,8 +79,6 @@ class CliArgs {
}
switch (arg) {
case '--new-note':
opts[arg] = '';
case '-h' || '--help':
modes.add(CliMode.help);
case '--text' || '--priority' || '--status' || '--data-dir':
@ -53,19 +86,12 @@ 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();
@ -77,9 +103,8 @@ 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, --set-text, '
'--close and '
'--backfill-created-by are mutually exclusive',
'--list, --search, --get, --delete, --set-status and --close are '
'mutually exclusive',
);
}
final mode = modes.isEmpty ? CliMode.add : modes.single;
@ -96,11 +121,6 @@ class CliArgs {
? null
: _byName(Status.values, rawStatus, '--status', _statusName),
dataDir: opts['--data-dir'],
newNote:
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'),
@ -114,8 +134,7 @@ class CliArgs {
/// The selected mode.
final CliMode mode;
/// Note text from `--text`; null means "read stdin" in [CliMode.add] and
/// [CliMode.setText].
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
final String? text;
/// Full id or 8+ character prefix of the note an id mode acts on.
@ -130,12 +149,6 @@ class CliArgs {
/// Overrides the default data dir (a second, independent node).
final String? dataDir;
/// `--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,
@ -151,7 +164,7 @@ class CliArgs {
static const _adding = 'when adding a note';
static const Map<String, (String, Set<CliMode>)> _scopes = {
'--text': ('$_adding or to --set-text', {CliMode.add, CliMode.setText}),
'--text': (_adding, {CliMode.add}),
'--done-check': (_adding, {CliMode.add}),
'--by': (_adding, {CliMode.add}),
'--repo': (
@ -159,8 +172,6 @@ class CliArgs {
{CliMode.add, CliMode.list, CliMode.search},
),
'--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

@ -3,8 +3,6 @@ import 'package:todo/cli/cli_session.dart';
import 'package:todo/cli/note_lookup.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
import 'package:todo/data/repo_note_split.dart';
import 'package:todo/sync/sync_service.dart';
import 'package:uuid/uuid.dart';
@ -20,25 +18,19 @@ class CliCommands {
final StringSink _out;
final StringSink _err;
/// Creates a note and pushes it. A `--repo` add joins the repo's open note.
/// Creates a note (a repo task when `--repo` is set) and pushes it.
Future<int> add(CliArgs args, String text) async {
final repo = args.opts.repo;
final status = args.status ?? Status.todo;
if (repo != null && kOpenStatuses.contains(status)) {
return await _addRepoTask(args, repo, text);
}
final now = DateTime.now();
final note = Note(
id: const Uuid().v4(),
text: text,
priority: args.priority,
status: status,
status: args.status ?? Status.todo,
createdAt: now,
updatedAt: now,
repo: repo,
repo: args.opts.repo,
doneCheck: args.opts.doneCheck,
by: args.opts.by,
createdBy: 'claude',
);
await session.repository.upsert(note);
if (!await _push('note')) return 1;
@ -46,55 +38,6 @@ class CliCommands {
return 0;
}
Future<int> _addRepoTask(CliArgs args, String repo, String text) async {
_report(await session.sync(push: true));
final (:title, :body) = splitNote(text);
if (args.newNote) return await _addSeparate(args, repo, title, body);
final added = await addRepoTask(
session.repository,
repo: repo,
title: title,
body: body,
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
createdBy: 'claude',
);
await _save(added.note);
if (!await _push('note')) return 1;
if (added.appended) _err.writeln('appended to the open note for $repo');
_out.writeln(added.note.id);
return 0;
}
Future<int> _addSeparate(
CliArgs args,
String repo,
String title,
String body,
) async {
final added = await addSeparateTask(
session.repository,
repo: repo,
title: title,
body: body,
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
createdBy: 'claude',
);
await _save(added.note);
if (!await _push('note')) return 1;
if (added.kind == SplitKind.merged) {
_err.writeln(
'merged into the separate note with that title; '
'click Start on the board by hand',
);
}
_out.writeln(added.note.id);
return 0;
}
/// Prints [listLine] for each note matching the filters in [args].
Future<int> list(CliArgs args) async {
_report(await session.sync(push: false));
@ -102,7 +45,7 @@ class CliCommands {
filter: NoteFilter(
query: args.opts.query ?? '',
statuses: {?args.status},
repo: args.opts.repo,
sources: {?args.opts.repo},
),
);
notes.map(listLine).forEach(_out.writeln);
@ -136,15 +79,6 @@ 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);
@ -154,35 +88,6 @@ 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);

View File

@ -1,47 +0,0 @@
/// 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 || args.mode == CliMode.setText) {
if (args.mode == CliMode.add) {
text = args.text ?? await readStdin();
if (text.trim().isEmpty) {
err.writeln('error: empty note (pass --text or pipe text on stdin)');
@ -79,12 +79,7 @@ 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

@ -7,7 +7,6 @@
/// records hand-written into RTDB.
library;
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
@ -30,38 +29,11 @@ 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, {
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;
CliSession(this.dataDir, this.repository, this._firebase);
/// File holding this install's persisted CRDT node id.
static const nodeIdFileName = 'node_id';
@ -120,17 +92,7 @@ class CliSession {
auth: auth,
httpClient: httpClient,
);
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'),
),
);
return CliSession(dataDir, repository, firebase);
}
/// Pull every peer's log and merge it; with [push], also publish this
@ -138,91 +100,15 @@ 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}) 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,
// Argon2id once per install, not on every CLI/MCP call.
keyCache: E2eKeyCache(read: _readCachedKey, write: _writeCachedKey),
);
Future<SyncResult> sync({required bool push}) {
if (!push) {
// 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 const SyncService().sync(repository, PullOnlyStore(_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);
/// Owner-only file in the data dir caching the derived E2E key.
static const e2eKeyFileName = 'e2e_key';
File get _keyFile => File(p.join(dataDir, e2eKeyFileName));
Future<List<int>?> _readCachedKey() async {
final file = _keyFile;
if (!file.existsSync()) return null;
try {
return base64.decode((await file.readAsString()).trim());
} on FormatException {
return null;
}
}
Future<void> _writeCachedKey(List<int> bytes) async {
final file = _keyFile;
// Restrict before the key is written, never after.
await file.writeAsString('');
await Process.run('chmod', ['600', file.path]);
await file.writeAsString(base64.encode(bytes), flush: true);
}
/// 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;
return SyncService(
stateStore: PersistedSyncStateStore(
FileLogPersistence(File(p.join(dataDir, syncStateFileName))),
),
).sync(repository, _firebase);
}
/// Releases the log and the HTTP client.

View File

@ -13,8 +13,6 @@ A note with --repo is a repo task; one without is a plain idea.
--repo <owner/name> repo task's repository, e.g. kuhyx/todo;
with --list/--search: show only that repo's tasks
--done-check <cmd> shell command, run from the repo root, exit 0 = done
--new-note with --repo: own note, not the shared one (an exact
title in another separate note merges into it)
--by <who> claude|kuhy
--list pull, then print live notes, one per line:
8-char id, status, priority, repo (- for none), title
@ -22,14 +20,9 @@ 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

@ -1,78 +0,0 @@
/// `bin/merge_repo_notes.dart`: collapse each repo's open notes into one.
/// Dry run unless `--apply`; stdout is the `old_id<TAB>kept_id<TAB>repo` map.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:path/path.dart' as p;
import 'package:todo/cli/cli_runner.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/data/repo_note_merge.dart';
const _usage = 'usage: merge_repo_notes [--dry-run|--apply] [--data-dir D]';
/// Runs the merge for [argv]; returns the exit code.
Future<int> runMergeRepoNotes(
List<String> argv, {
required Map<String, String> env,
required StringSink out,
required StringSink err,
SessionOpener open = CliSession.open,
}) async {
var apply = false;
String? dataDir;
for (var i = 0; i < argv.length; i++) {
switch (argv[i]) {
case '--apply':
apply = true;
case '--dry-run':
apply = false;
case '--data-dir' when i + 1 < argv.length:
dataDir = argv[++i];
default:
err.writeln(_usage);
return 2;
}
}
CliSession? session;
try {
session = await open(
dataDir ?? defaultDataDir(env),
seedCredentials: p.join(
env['HOME'] ?? '',
'.config',
'todo',
'firebase_auth.json',
),
);
await session.sync(push: apply);
final plan = planRepoMerges(await session.repository.listNotes());
for (final (:kept, :removed) in plan) {
if (apply) {
await session.repository.upsert(
kept.copyWith(updatedAt: DateTime.now()),
);
for (final n in removed) {
await session.repository.delete(n.id);
}
}
for (final n in removed) {
out.writeln('${n.id}\t${kept.id}\t${kept.repo}');
}
}
final deleted = plan.fold(0, (sum, m) => sum + m.removed.length);
err.writeln(
'${apply ? 'applied' : 'dry run'}: ${plan.length} repos, '
'$deleted notes deleted',
);
if (apply && plan.isNotEmpty && !(await session.sync(push: true)).pushed) {
err.writeln('error: saved locally but nothing was pushed');
return 1;
}
return 0;
} on RemoteSyncError catch (error) {
err.writeln('error: $error');
return 1;
} finally {
await session?.close();
}
}

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>\t<created_by>`.
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`.
String listLine(Note note) => [
if (note.id.length <= _minPrefix)
note.id
@ -42,7 +42,6 @@ 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;
@ -58,7 +57,6 @@ 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,14 +19,10 @@ import 'package:shared_preferences/shared_preferences.dart';
/// sibling to `changesets/<nodeId>.json`.
const String kAdvancedModeRemotePath = 'settings/advancedMode';
/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
/// App-wide, non-sync preferences (currently just `advancedMode`).
class AppSettings {
/// Creates an [AppSettings] from its stored fields.
const AppSettings({
required this.advancedMode,
this.advancedModeUpdatedAt,
this.showClaudeNotes = false,
});
const AppSettings({required this.advancedMode, this.advancedModeUpdatedAt});
/// Whether the priority/status/template/mode chrome and the routine
/// save/sync status text are shown. Off by default: casual capture needs
@ -39,11 +35,6 @@ 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';
@ -53,7 +44,6 @@ 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),
@ -81,7 +71,6 @@ class AppSettings {
final updated = AppSettings(
advancedMode: value,
advancedModeUpdatedAt: updatedAt,
showClaudeNotes: showClaudeNotes,
);
if (client != null) {
try {
@ -100,17 +89,6 @@ 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
@ -122,7 +100,9 @@ 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,
@ -130,7 +110,6 @@ 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

@ -1,44 +0,0 @@
/// 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,7 +73,11 @@ 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',

View File

@ -1,38 +0,0 @@
/// 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` and `created_by` fields may hold.
/// The values the optional `by` field may hold: who owns a repo task.
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
/// An immutable idea/note record.
@ -99,7 +99,6 @@ class Note {
this.doneCheck,
this.by,
this.evidence,
this.createdBy,
});
/// Builds a [Note] from a raw CRDT query row.
@ -147,18 +146,9 @@ 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;
/// The repo without its owner ("kartka" for "kuhyx/kartka"), or null.
String? get repoName => repo?.split('/').last;
/// Returns a copy with selected fields replaced.
Note copyWith({
String? text,
@ -169,7 +159,6 @@ class Note {
String? doneCheck,
String? by,
String? evidence,
String? createdBy,
}) {
return Note(
id: id,
@ -182,7 +171,6 @@ class Note {
doneCheck: doneCheck ?? this.doneCheck,
by: by ?? this.by,
evidence: evidence ?? this.evidence,
createdBy: createdBy ?? this.createdBy,
);
}
}

View File

@ -1,63 +0,0 @@
/// 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

@ -9,6 +9,12 @@ library;
import 'package:todo/data/note.dart';
/// [NoteFilter.sources] entry for plain ideas (notes without a repo).
const kIdeasSource = '';
/// [NoteFilter.sources] entry admitting every repo task.
const kRepoTasksSource = '*';
/// How the history list should be ordered.
enum NoteSort {
/// Newest created first.
@ -35,9 +41,7 @@ class NoteFilter {
this.query = '',
this.priorities = const {},
this.statuses = const {},
this.repo,
this.repoTasksOnly = false,
this.hideClaude = false,
this.sources = const {},
this.createdFrom,
this.createdTo,
this.updatedFrom,
@ -53,15 +57,9 @@ class NoteFilter {
/// Notes must have one of these statuses. Empty means "any status".
final Set<Status> statuses;
/// Only notes whose repo equals this. Null means any repo (or none).
final String? repo;
/// 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;
/// Which notes may show: [kIdeasSource], [kRepoTasksSource] and/or repo
/// names. Empty means every note.
final Set<String> sources;
/// Inclusive lower bound (by calendar day) on the creation date.
final DateTime? createdFrom;
@ -80,8 +78,7 @@ class NoteFilter {
query.trim().isEmpty &&
priorities.isEmpty &&
statuses.isEmpty &&
repo == null &&
!repoTasksOnly &&
sources.isEmpty &&
createdFrom == null &&
createdTo == null &&
updatedFrom == null &&
@ -93,8 +90,7 @@ class NoteFilter {
if (query.trim().isNotEmpty) n++;
if (priorities.isNotEmpty) n++;
if (statuses.isNotEmpty) n++;
if (repo != null) n++;
if (repoTasksOnly) n++;
if (sources.isNotEmpty) n++;
if (createdFrom != null || createdTo != null) n++;
if (updatedFrom != null || updatedTo != null) n++;
return n;
@ -107,9 +103,7 @@ class NoteFilter {
String? query,
Set<Priority>? priorities,
Set<Status>? statuses,
String? repo,
bool? repoTasksOnly,
bool? hideClaude,
Set<String>? sources,
DateTime? createdFrom,
DateTime? createdTo,
DateTime? updatedFrom,
@ -121,9 +115,7 @@ class NoteFilter {
query: query ?? this.query,
priorities: priorities ?? this.priorities,
statuses: statuses ?? this.statuses,
repo: repo ?? this.repo,
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
hideClaude: hideClaude ?? this.hideClaude,
sources: sources ?? this.sources,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
@ -143,9 +135,9 @@ bool matchesFilter(Note note, NoteFilter filter) {
!(note.repo?.toLowerCase().contains(query) ?? false)) {
return false;
}
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.sources.isNotEmpty && !_sourceAllowed(note, filter.sources)) {
return false;
}
if (filter.priorities.isNotEmpty &&
!filter.priorities.contains(note.priority)) {
return false;
@ -162,6 +154,12 @@ bool matchesFilter(Note note, NoteFilter filter) {
return true;
}
bool _sourceAllowed(Note note, Set<String> sources) {
final repo = note.repo;
if (repo == null) return sources.contains(kIdeasSource);
return sources.contains(kRepoTasksSource) || sources.contains(repo);
}
/// Inclusive day-granularity range check, matching the old SQL bounds:
/// `from` includes its whole day; `to` includes its whole day.
bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
@ -173,17 +171,8 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
return true;
}
/// The comparator implementing [sort]: human-made notes first, Claude-made
/// ones after, and [sort] orders each group.
/// The comparator implementing [sort].
Comparator<Note> comparatorFor(NoteSort sort) {
final within = _within(sort);
return (a, b) {
if (a.isClaudeMade != b.isClaudeMade) return a.isClaudeMade ? 1 : -1;
return within(a, b);
};
}
Comparator<Note> _within(NoteSort sort) {
switch (sort) {
case NoteSort.createdDesc:
return (a, b) => b.createdAt.compareTo(a.createdAt);

View File

@ -14,9 +14,6 @@ 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) => {
@ -29,7 +26,6 @@ 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.
@ -52,7 +48,6 @@ Note noteFromRecord(Record record) {
doneCheck: task(_doneCheck),
by: task(_by),
evidence: task(_evidence),
createdBy: task(kCreatedByField),
);
}

View File

@ -1,8 +1,7 @@
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';
@ -115,57 +114,9 @@ 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 _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));
await _store.upsert(
Record(id: note.id, fields: noteFields(note, _store.nextHlc())),
);
}
/// Soft-deletes a note. The log keeps a sticky tombstone so the deletion
@ -191,10 +142,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 _put(recordAtNoteTime(note, _nodeId));
await _store.upsert(recordAtNoteTime(note, _nodeId));
added++;
} else if (note.updatedAt.isAfter(local.updatedAt)) {
await _put(recordAtNoteTime(note, _nodeId));
await _store.upsert(recordAtNoteTime(note, _nodeId));
updated++;
} else {
skipped++;
@ -214,10 +165,27 @@ class NoteRepository {
Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.priorityDesc,
NoteFilter filter = const NoteFilter(),
}) => watchLog(_store.changes, () => _query(sort, filter));
}) => _watch(() => _query(sort, filter));
/// Emits the live count of non-deleted notes immediately and on each change.
Stream<int> watchCount() => watchLog(_store.changes, _liveCount);
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;
}
/// This device's stable node id. Names its file in the sync repo so two
/// devices never write the same path.

View File

@ -1,75 +0,0 @@
/// 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

@ -1,107 +0,0 @@
/// The one-open-note-per-repo invariant. Single add path for the MCP
/// `add_task` and the CLI `--repo` add; callers persist and push.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:uuid/uuid.dart';
/// Statuses that count as open.
const Set<Status> kOpenStatuses = {Status.todo, Status.inProgress};
/// The repo note's title.
String repoNoteTitle(String repo) => '${repo.split('/').last} todos';
/// A `new_note` task's own note title: `<name> todos: <title>`.
String separateNoteTitle(String repo, String title) =>
'${repoNoteTitle(repo)}: ${title.trim()}';
/// Whether [note] is a `new_note` note, which the shared add path and the
/// one-time merge leave alone.
bool isSeparateNote(Note note) {
final repo = note.repo;
return repo != null &&
splitNote(note.text).title.startsWith('${repoNoteTitle(repo)}: ');
}
/// Joins two checks with `&&`; either may be absent.
String? composeDoneCheck(String? older, String? newer) {
final a = (older ?? '').trim();
final b = (newer ?? '').trim();
if (a.isEmpty) return b.isEmpty ? null : b;
return b.isEmpty ? a : '( $a ) && ( $b )';
}
/// A `## title` section.
String repoSection(String title, String body) {
final text = body.trim();
return text.isEmpty ? '## $title' : '## $title\n$text';
}
/// The higher priority.
Priority maxPriority(Priority a, Priority b) => a.value >= b.value ? a : b;
/// Title line and rest of a note's text.
({String title, String body}) splitNote(String text) {
final lines = text.split('\n');
final first = lines.indexWhere((l) => l.trim().isNotEmpty);
if (first < 0) return (title: '', body: '');
return (
title: lines[first].trim().replaceFirst(RegExp(r'^#+\s*'), ''),
body: lines.skip(first + 1).join('\n').trim(),
);
}
/// Oldest first; ties broken by id so the choice is deterministic.
int byAge(Note a, Note b) {
final c = a.createdAt.compareTo(b.createdAt);
return c != 0 ? c : a.id.compareTo(b.id);
}
/// The note to save and whether it was appended.
typedef RepoAdd = ({Note note, bool appended});
/// Appends the task to [repo]'s oldest open shared note, or builds that
/// note; separate (`new_note`) notes are never joined.
Future<RepoAdd> addRepoTask(
NoteRepository repository, {
required String repo,
required String title,
String body = '',
String? doneCheck,
Priority priority = Priority.medium,
String? by,
String? createdBy,
}) async {
final section = repoSection(title, body);
final open = (await repository.listNotes(
filter: NoteFilter(statuses: kOpenStatuses, repo: repo),
)).where((n) => !isSeparateNote(n)).toList();
if (open.isNotEmpty) {
final existing = (open..sort(byAge)).first;
return (
note: existing.copyWith(
text: '${existing.text}\n\n$section',
doneCheck: composeDoneCheck(existing.doneCheck, doneCheck),
priority: maxPriority(existing.priority, priority),
),
appended: true,
);
}
final now = DateTime.now();
return (
note: Note(
id: const Uuid().v4(),
text: '${repoNoteTitle(repo)}\n\n$section',
priority: priority,
status: Status.todo,
createdAt: now,
updatedAt: now,
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
appended: false,
);
}

View File

@ -1,53 +0,0 @@
/// One-time migration plan: one open note per repo, oldest id kept.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/repo_note.dart';
/// The rewritten oldest note and the originals to delete.
typedef RepoMerge = ({Note kept, List<Note> removed});
/// A merge for every repo whose open notes are not yet one `<name> todos`
/// note; running it on its own result yields an empty plan. Separate
/// (`new_note`) notes stay out of it.
List<RepoMerge> planRepoMerges(Iterable<Note> notes) {
final byRepo = <String, List<Note>>{};
for (final n in notes) {
final repo = n.repo;
if (repo != null &&
kOpenStatuses.contains(n.status) &&
!isSeparateNote(n)) {
byRepo.putIfAbsent(repo, () => []).add(n);
}
}
final plan = <RepoMerge>[];
for (final MapEntry(key: repo, value: group) in byRepo.entries) {
group.sort(byAge);
final title = repoNoteTitle(repo);
if (group.length == 1 && splitNote(group.single.text).title == title) {
continue;
}
plan.add((kept: _merged(title, group), removed: group.sublist(1)));
}
return plan;
}
Note _merged(String title, List<Note> group) {
var doneCheck = group.first.doneCheck;
var priority = group.first.priority;
final sections = <String>[];
for (final (i, n) in group.indexed) {
final (title: t, :body) = splitNote(n.text);
// An already-merged note keeps its sections as they are.
sections.add(t == title ? body : repoSection(t, body));
if (i > 0) {
doneCheck = composeDoneCheck(doneCheck, n.doneCheck);
priority = maxPriority(priority, n.priority);
}
}
return group.first.copyWith(
text: '$title\n\n${sections.where((s) => s.isNotEmpty).join('\n\n')}',
doneCheck: doneCheck,
priority: priority,
);
}

View File

@ -1,122 +0,0 @@
/// `new_note`: a repo task in its own open note, `<name> todos: <title>`,
/// so it can close on its own done_check. A title that already heads an open
/// section of another separate note of the repo merges into that note
/// instead (it keeps its id); the shared `<name> todos` note never counts.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
import 'package:uuid/uuid.dart';
/// How a separate add landed.
enum SplitKind {
/// A new separate note.
added,
/// Appended to the separate note that already had the title.
merged,
/// A new separate note because the duplicate's note was busy.
fellBack,
}
/// The note to save, how it landed, and why it fell back.
typedef SplitAdd = ({Note note, SplitKind kind, String? why});
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
/// 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) 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 =
open
.where((n) => n.repo == repo && isSeparateNote(n))
.where((n) => openSections(n.text).contains(title.trim()))
.toList()
..sort(byAge);
return hits.isEmpty ? null : hits.first;
}
/// Why a merged task must not be started, or null when it may be.
String? startBlock(String? doneCheck, String body) {
final check = (doneCheck ?? '').trim().toLowerCase();
if (check.isEmpty || check == 'none') return 'done_check is none';
final first = body
.split('\n')
.firstWhere((l) => l.trim().isNotEmpty, orElse: () => '');
return first.trim().toLowerCase().startsWith('low priority')
? 'marked low priority'
: null;
}
/// Adds the task as its own note, or merges it into the duplicate's note
/// unless [busy] says a board session is working on it.
Future<SplitAdd> addSeparateTask(
NoteRepository repository, {
required String repo,
required String title,
String body = '',
String? doneCheck,
Priority priority = Priority.medium,
String? by,
String? createdBy,
Future<bool> Function(Note note)? busy,
}) async {
final section = repoSection(title, body);
final open = await repository.listNotes(
filter: NoteFilter(statuses: kOpenStatuses, repo: repo),
);
final target = duplicateTarget(open, repo, title);
String? why;
if (target != null) {
if (busy == null || !await busy(target)) {
return (
note: target.copyWith(
text: '${target.text}\n\n$section',
doneCheck: composeDoneCheck(target.doneCheck, doneCheck),
priority: maxPriority(target.priority, priority),
),
kind: SplitKind.merged,
why: null,
);
}
why =
'not merged into ${target.id.substring(0, 8)}: '
'a board session is running on it';
}
final now = DateTime.now();
return (
note: Note(
id: const Uuid().v4(),
text: '${separateNoteTitle(repo, title)}\n\n$section',
priority: priority,
status: Status.todo,
createdAt: now,
updatedAt: now,
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
kind: why == null ? SplitKind.added : SplitKind.fellBack,
why: why,
);
}

View File

@ -7,11 +7,6 @@ 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';
@ -91,9 +86,6 @@ 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
@ -192,9 +184,6 @@ 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,23 +121,4 @@ 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

@ -132,16 +132,10 @@ class ImageCacheStore {
for (final ref in pending()) {
final file = fileFor(ref);
if (!file.existsSync()) continue;
try {
final length = await file.length();
if (await client.upload(ref, file.openRead(), length)) {
await _marker(ref).delete();
uploaded++;
}
} on FileSystemException {
// Deleted mid-pass (note removed, cache cleared): nothing left to
// upload. A surviving marker is skipped by the check above next time.
continue;
final length = await file.length();
if (await client.upload(ref, file.openRead(), length)) {
await _marker(ref).delete();
uploaded++;
}
}
for (final ref in pass.referenced) {

View File

@ -11,7 +11,6 @@ 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();
@ -70,7 +69,6 @@ class TodoApp extends StatelessWidget {
store: imageStore,
child: MaterialApp(
title: 'todo',
localizationsDelegates: kTextFreeLocalizations,
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
home: CaptureScreen(

View File

@ -1,167 +0,0 @@
/// The todoImplementer board as `add_task new_note` sees it: whether a
/// note has a session running, and a Start click on a merged task. Start
/// goes through the board's own page (CSRF meta tag, same Origin), so the
/// board needs no new endpoint. Failures come back as reasons, never throws:
/// 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;
/// Where the board listens (loopback only).
final Uri kBoardBase = Uri.parse('http://127.0.0.1:8786');
/// A Start attempt: the item started, or why not.
typedef StartResult = ({bool ok, String? reason, String? item});
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. [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;
/// 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);
if (res.statusCode != 200) {
throw http.ClientException('GET /api/board ${res.statusCode}');
}
final tasks = _json(res)['tasks'];
final prefix = '${noteId.substring(0, 8)}-';
return [
for (final t in (tasks as List).cast<Map<String, dynamic>>())
if ((t['id']! as String).startsWith(prefix)) t,
];
}
/// Whether any item of the note is in the board's Running column; false
/// when the board cannot be reached.
Future<bool> running(String noteId) async {
try {
return (await _cards(noteId)).any((c) => c['column'] == 'running');
} on Object {
return false;
}
}
/// 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 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;
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);
static String _message(http.Response res) {
try {
final error = _json(res)['error'];
return (error as Map<String, dynamic>)['message'] as String;
} on Object {
return res.body;
}
}
// The board's JSON is UTF-8 whatever the Content-Type says.
static Map<String, dynamic> _json(http.Response res) =>
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
static StartResult _no(String reason) =>
(ok: false, reason: reason, item: null);
}

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:todo/cli/cli_session.dart';
import 'package:todo/mcp/board_client.dart';
import 'package:todo/mcp/task_service.dart';
import 'package:todo/mcp/todo_mcp_server.dart';
@ -31,7 +30,7 @@ Future<Future<void> Function()?> startTodoMcp(
);
await session.sync(push: true);
final server = await TodoMcpServer.start(
tasks: TaskService(session, board: BoardClient()),
tasks: TaskService(session),
tokens: tokens,
port: int.tryParse(env['TODO_MCP_PORT'] ?? '') ?? kTodoMcpPort,
);

View File

@ -3,21 +3,17 @@ import 'package:todo/cli/cli_session.dart';
import 'package:todo/cli/note_lookup.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
import 'package:todo/mcp/board_client.dart';
import 'package:todo/mcp/task_split.dart';
import 'package:uuid/uuid.dart';
/// The six task tools over one persistent sync node; calls run one at a time.
class TaskService {
/// Serves [_session]'s notes; every call pulls first, writes then push.
/// [board] is the todoImplementer board `new_note` checks and starts on.
TaskService(this._session, {this._board});
TaskService(this._session);
final CliSession _session;
final BoardClient? _board;
Future<void> _tail = Future.value();
Future<T> _serial<T>(Future<T> Function() body) {
Future<String> _serial(Future<String> Function() body) {
final run = _tail.then((_) => body());
_tail = run.then((_) {}, onError: (_) {});
return run;
@ -44,43 +40,29 @@ class TaskService {
}
}
/// `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 {
/// `add_task`: a new repo task by claude; returns its full id.
Future<String> add(Map<String, dynamic> args) => _serial(() async {
final a = _Args(args);
final repo = a.repo(required: true)!;
final title = a.required('title');
final body = a.optional('body') ?? '';
final now = DateTime.now();
await _pull();
if (a.flag('new_note')) {
return await addSplit(
_session.repository,
_board,
_save,
repo: repo,
title: title,
body: a.optional('body') ?? '',
doneCheck: a.optional('done_check'),
return await _save(
Note(
id: const Uuid().v4(),
text: body.isEmpty ? title : '$title\n\n$body',
priority: a.priority() ?? Priority.medium,
);
}
final added = await addRepoTask(
_session.repository,
repo: repo,
title: title,
body: a.optional('body') ?? '',
doneCheck: a.optional('done_check'),
priority: a.priority() ?? Priority.medium,
by: 'claude',
createdBy: 'claude',
status: Status.todo,
createdAt: now,
updatedAt: now,
repo: repo,
doneCheck: a.optional('done_check'),
by: 'claude',
),
'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 {
@ -90,7 +72,10 @@ class TaskService {
await _pull();
return _lines(
await _session.repository.listNotes(
filter: NoteFilter(statuses: statuses, repo: repo, repoTasksOnly: true),
filter: NoteFilter(
statuses: statuses,
sources: {repo ?? kRepoTasksSource},
),
),
);
});
@ -101,7 +86,7 @@ class TaskService {
await _pull();
return _lines(
await _session.repository.listNotes(
filter: NoteFilter(query: query, repoTasksOnly: true),
filter: NoteFilter(query: query, sources: {kRepoTasksSource}),
),
);
});
@ -163,12 +148,6 @@ final Map<String, Status> _statuses = {
extension type _Args(Map<String, dynamic> _args) {
bool has(String name) => _args.containsKey(name);
bool flag(String name) => switch (_args[name]) {
null || false => false,
true => true,
_ => throw FormatException('$name: must be a boolean'),
};
String? optional(String name) {
final value = _args[name];
if (value == null) return null;

View File

@ -1,67 +0,0 @@
/// `add_task` with `new_note: true`: the separate note, the guard merge and
/// the board Start that follows a merge, as one reply text.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note_split.dart';
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);
/// 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, {
required String repo,
required String title,
required String body,
required String? doneCheck,
required Priority priority,
}) async {
final added = await addSeparateTask(
repository,
repo: repo,
title: title,
body: body,
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 (
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: '$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

@ -14,12 +14,9 @@ Tasks for kuhy's repos live in the todo app (not in TODO*.md files, which are
banned). Use this server instead of writing a TODO file.
- At session start in a repo, call list_tasks(repo: "kuhyx/<name>") to see
its open tasks.
- A repo has ONE open note, "<repo> todos", holding all its tasks as
"## title" sections. add_task appends to it (or creates it) and returns its id.
- When you find work you will not finish now, call add_task with the repo,
a one-line title, the body (what / where / done / verify) and a done_check:
a shell command run from the repo root that exits 0 once the work is done.
Done checks of appended tasks are joined with &&.
- When a task's work lands, run its done_check; only if it exits 0 call
close_task with evidence "<commit sha> done_check exit 0". Never close
without running it.
@ -93,22 +90,14 @@ McpServer _server(TaskService tasks) {
tool(
'add_task',
"Add a task to the repo's single open note (by: claude): appended as a "
'"## title" section, or the note is created. Returns "added <full id>" '
'or "appended to <full id>", then its list line. Use when you find '
'work you will not finish now. With new_note: true the task gets '
'its own note ("added"), or, when another separate note of the repo '
'already has that exact title, "merged into" that note, followed by '
'a "start:" line for the todoImplementer board.',
'Add an open task for a repo (by: claude). Returns "added <full id>" and '
'its list line. Use when you find work you will not finish now.',
{
'repo': repo,
'title': JsonSchema.string(description: 'One-line title.'),
'body': JsonSchema.string(description: 'Markdown: what/where/verify.'),
'done_check': doneCheck,
'priority': priority,
'new_note': JsonSchema.boolean(
description: 'Own note instead of joining the shared one.',
),
},
['repo', 'title'],
tasks.add,
@ -116,8 +105,7 @@ McpServer _server(TaskService tasks) {
tool(
'list_tasks',
'List repo tasks, one line each: 8-char id, status, priority, repo, '
'title, created_by (kuhy|claude). Default status "open" '
'(todo + in_progress).',
'title. Default status "open" (todo + in_progress).',
{
'repo': repo,
'status': JsonSchema.string(

View File

@ -0,0 +1,27 @@
import 'dart:developer';
import 'package:flutter/services.dart';
/// Talks to `AccountTokenChannel.kt`: system account chooser, then
/// AccountManager's `audience:server:client_id:` token. No browser.
const kAccountTokenChannel = MethodChannel('dev.kuhy.todo/account_token');
/// A Google ID token for [serverClientId], or null on cancel or failure.
Future<String?> accountManagerIdToken({
required String serverClientId,
MethodChannel channel = kAccountTokenChannel,
}) async {
try {
return await channel.invokeMethod<String>('idToken', {
'serverClientId': serverClientId,
});
} on PlatformException catch (error, stackTrace) {
log(
'AccountManager token failed',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
}
}

View File

@ -1,38 +0,0 @@
/// 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

@ -3,8 +3,8 @@
/// Why this is its own file: `crdt_sync` is deliberately pure Dart (only
/// `crypto` and `http`), because the same library runs on Linux desktop and
/// headless under systemd, where Android's AccountManager does not exist. So
/// the platform channel lives in the google_account_token plugin, and the
/// library only ever receives a token string through a closure.
/// the platform channel lives in the app, and the library only ever receives
/// a token string through a closure.
///
/// What this buys: on a fresh install the sync account is reached by tapping
/// one account-picker row instead of typing a long password on a phone
@ -14,7 +14,7 @@ library;
import 'dart:developer';
import 'package:google_account_token/google_account_token.dart' as plugin;
import 'package:todo/sync/account_token.dart';
import 'package:todo/sync/google_platform.dart' as platform_gate;
/// The account the security rules pin.
@ -70,7 +70,7 @@ bool get googleSignInSupported =>
/// an ordinary outcome, and the caller falls back to the password path.
///
/// [signInFn] and [serverClientId] exist for tests, which cannot reach the
/// platform channel the plugin uses.
/// platform channel `account_token.dart` uses.
Future<String?> googleIdToken({
Future<String?> Function()? signInFn,
String serverClientId = kServerClientId,
@ -86,5 +86,5 @@ Future<String?> googleIdToken({
);
return null;
}
return await plugin.googleIdToken(serverClientId: serverClientId);
return await accountManagerIdToken(serverClientId: serverClientId);
}

View File

@ -92,7 +92,6 @@ class NotesMarkdown {
doneCheck: attrs['done_check'],
by: attrs['by'],
evidence: attrs['evidence'],
createdBy: attrs['created_by'],
),
);
}
@ -114,7 +113,6 @@ 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,26 +7,15 @@
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.
@ -55,12 +44,8 @@ class SyncRunResult {
/// rather than each concern (notes, settings, analytics) opening and
/// closing its own.
///
/// [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.
/// [firebaseFactory] and [stateStore] exist for tests, which must not reach
/// the OS keystore or an application-support directory.
Future<SyncRunResult> runSync(
NoteRepository repository,
SyncSettings settings, {
@ -69,7 +54,6 @@ Future<SyncRunResult> runSync(
http.Client? httpClient,
Future<FirebaseRestClient?> Function()? firebaseFactory,
SyncStateStore? stateStore,
Future<String?> Function()? readPassphrase,
}) async {
final github = GitHubClient(
owner: settings.owner,
@ -88,29 +72,12 @@ Future<SyncRunResult> runSync(
stateStore: stateStore ?? await openSyncStateStore(),
// coverage:ignore-end
);
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),
final result = await service.sync(
repository,
firebase == null
? github
: MirrorStore(primary: firebase, mirror: github),
);
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,7 +11,6 @@ class SyncResult {
this.skippedFiles = const [],
this.skippedUnchanged = 0,
this.firebaseConnected = false,
this.locked = false,
});
/// How many other devices' logs were pulled and merged.
@ -42,10 +41,6 @@ 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
@ -56,7 +51,6 @@ class SyncResult {
skippedFiles: skippedFiles,
skippedUnchanged: skippedUnchanged,
firebaseConnected: value,
locked: locked,
);
@override
@ -79,11 +73,7 @@ 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,
this.revsPath,
});
const SyncService({this.notesDir = 'todo-sync/notes', this.stateStore});
/// Directory in the repo under which per-device note-log files live.
final String notesDir;
@ -97,11 +87,7 @@ class SyncService {
final SyncStateStore? stateStore;
/// Where revisions live, as a sibling of [notesDir].
/// Revision cache directory; null derives it from [notesDir].
final String? revsPath;
/// Where peers publish their revisions.
String get revsDir => revsPath ?? defaultRevsPath(notesDir);
String get revsDir => defaultRevsPath(notesDir);
/// Runs a full pull-merge-push cycle. Safe to call repeatedly.
///
@ -137,15 +123,7 @@ class SyncService {
skippedUnchanged++;
continue;
}
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 text = await remote.getFileText('$notesDir/$name');
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,26 +1,28 @@
/// The one page route every screen is pushed with.
/// The one page route every screen is pushed with, on the shared motion tokens.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/ui/motion.dart';
/// 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);
/// 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),
);
class _AppPageRoute<T> extends MaterialPageRoute<T> {
_AppPageRoute({required super.builder});
_AppPageRoute({required super.builder, required this.duration});
final Duration duration;
@override
Duration get transitionDuration => Duration.zero;
Duration get transitionDuration => duration;
@override
Duration get reverseTransitionDuration => Duration.zero;
Duration get reverseTransitionDuration => duration;
@override
Widget buildTransitions(
@ -28,5 +30,21 @@ class _AppPageRoute<T> extends MaterialPageRoute<T> {
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) => child;
) {
final enter = CurvedAnimation(
parent: animation,
curve: AppCurve.decelerate,
reverseCurve: AppCurve.accelerate,
);
return FadeTransition(
opacity: enter,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.04),
end: Offset.zero,
).animate(enter),
child: child,
),
);
}
}

View File

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

View File

@ -86,11 +86,13 @@ 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, semanticLabel: 'New note'),
icon: const Icon(Icons.check),
),
if (advanced)
IconButton(
tooltip: 'Sync',
onPressed: syncing ? null : onSync,
icon: syncing
? const SizedBox(
@ -98,11 +100,12 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync, semanticLabel: 'Sync'),
: const Icon(Icons.sync),
),
IconButton(
tooltip: 'Notes',
onPressed: onOpenList,
icon: const Icon(Icons.list, semanticLabel: 'Notes'),
icon: const Icon(Icons.list),
),
// Live count of stored notes, proving local persistence. Sits directly
// right of the Notes button it belongs to, and carries no icon of its
@ -119,8 +122,9 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
),
CaptureConfirmMark(captured: captured),
IconButton(
tooltip: 'Settings',
onPressed: onOpenSettings,
icon: const Icon(Icons.settings, semanticLabel: 'Settings'),
icon: const Icon(Icons.settings),
),
],
);

View File

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

View File

@ -1,6 +1,7 @@
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;
@ -21,7 +22,6 @@ 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,14 +194,7 @@ class _CaptureScreenState extends State<CaptureScreen>
Future<void> _sync() async {
final settings = _settings ?? await SyncSettings.load();
if (!settings.isConfigured && await openFirebase() == null) {
// 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',
);
}
_showSnack('Connect Firebase (or add a GitHub token) in settings');
await _openSettings();
return;
}
@ -222,7 +215,12 @@ class _CaptureScreenState extends State<CaptureScreen>
if (outcome.ok) 'detail': outcome.detail,
},
);
_showSyncCue(outcome);
_showSnack(
outcome.ok
? 'Synced: ${outcome.detail}'
: 'Sync failed: ${outcome.detail}',
isError: !outcome.ok,
);
} finally {
if (mounted) setState(() => _syncing = false);
}

View File

@ -104,20 +104,13 @@ 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.
/// 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) {
void _showSnack(String message, {bool isError = false}) {
if (!mounted) return;
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);
if (isError) {
showError(context, message);
} else {
showToast(context, message);
}
}
/// Reloads the persisted last-sync outcome so the status line survives a

View File

@ -98,7 +98,6 @@ 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: _cueLine(
Icons.sync_problem,
formatSyncTime(status.time),
child: Text(
'Sync failed at ${formatSyncTime(status.time)} · '
'${status.detail}',
theme.textTheme.bodySmall,
theme.colorScheme.error,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
},
@ -63,45 +63,15 @@ class CaptureStatusFooter extends StatelessWidget {
padding: const EdgeInsets.only(top: 4),
child: ValueListenableBuilder<DateTime?>(
valueListenable: lastSavedAt,
// 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,
),
builder: (context, savedAt, _) => Text(
savedAt == null
? 'Autosaves as you type'
: 'Saved locally at ${formatSyncTime(savedAt)}',
style: theme.textTheme.bodySmall,
),
),
),
],
);
}
/// 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,16 +26,11 @@ 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;
@ -95,15 +90,10 @@ 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,7 +11,6 @@ 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';
@ -19,7 +18,6 @@ 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 =
@ -36,7 +34,8 @@ class CloudImagesTile extends StatelessWidget {
@override
Widget build(BuildContext context) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.cloud, semanticLabel: 'Cloud images'),
title: const Text('Cloud images'),
subtitle: const Text('Where attached images and files upload (dufs)'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
@ -70,7 +69,7 @@ class CloudImagesScreen extends StatefulWidget {
class _CloudImagesScreenState extends State<CloudImagesScreen> {
final _password = TextEditingController();
ImageStoreStatus? _status;
StatusCue? _checkResult;
String? _checkResult;
bool _checking = false;
@override
@ -99,11 +98,7 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
final password = _password.text.trim();
setState(() => _checking = true);
final saved = await widget.passwords.write(password);
var result = const StatusCue(
Icons.no_encryption,
'Could not save: the keystore is unavailable',
error: true,
);
var result = 'Could not save: the keystore is unavailable';
if (saved) {
final client = DufsImageClient(
DufsLogin(user: kDufsImagesUser, password: password),
@ -111,17 +106,9 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
);
final check = await client.check();
result = switch (check) {
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,
),
DufsCheck.ok => 'Saved — dufs accepted the login',
DufsCheck.rejected => 'Saved, but dufs rejected the password',
DufsCheck.unreachable => 'Saved — dufs is unreachable right now',
};
// 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
@ -142,9 +129,7 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
Widget build(BuildContext context) {
final status = _status;
return Scaffold(
appBar: AppBar(
title: const Icon(Icons.cloud_outlined, semanticLabel: 'Cloud images'),
),
appBar: AppBar(title: const Text('Cloud images')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -153,78 +138,36 @@ class _CloudImagesScreenState extends State<CloudImagesScreen> {
controller: _password,
obscureText: true,
decoration: const InputDecoration(
prefixIcon: Icon(
Icons.lock_outline,
semanticLabel: 'Password for the "todo" dufs login',
),
labelText: 'Password for the "todo" dufs login',
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _checking ? null : _saveAndTest,
child: const Icon(Icons.save, semanticLabel: 'Save & test'),
child: const Text('Save & test'),
),
if (_checkResult case final result?) ...[
const SizedBox(height: 8),
StatusCueView(result),
Text(result),
],
const SizedBox(height: 16),
],
if (status != null) ...[
// 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',
),
),
],
Text(
status.configured
? 'Login: configured'
: 'Login: missing — create it on the PC with',
),
if (!status.configured) const SelectableText(kAddLoginCommand),
const SizedBox(height: 8),
// 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}',
),
],
),
Text('Waiting to upload: ${status.pending}'),
],
const SizedBox(height: 16),
// 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),
],
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,
),
],
),

View File

@ -1,58 +0,0 @@
/// 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'),
),
],
),
);
}
}

View File

@ -1,58 +0,0 @@
/// 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

@ -1,23 +0,0 @@
/// 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',
);
}

View File

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

View File

@ -9,12 +9,12 @@ library;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/widgets.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,15 +73,11 @@ Future<void> attachImages(
target.insert([for (final ref in refs) ref.imageMarkdown]);
if (!context.mounted) return;
if (failure != null) {
showCue(context, Icons.cloud_off, failure, error: true);
showError(context, failure);
} else if (notImages > 0) {
// A broken-image glyph plus how many were refused.
showCue(
showError(
context,
Icons.broken_image_outlined,
notImages == 1 ? 'Not an image' : '$notImages files were not images',
error: true,
count: notImages,
);
}
}
@ -108,7 +104,5 @@ Future<void> attachFiles(
}
}
target.insert([for (final attachment in attached) attachment.markdown]);
if (failure != null && context.mounted) {
showCue(context, Icons.cloud_off, failure, error: true);
}
if (failure != null && context.mounted) showError(context, failure);
}

View File

@ -8,7 +8,6 @@ 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';
@ -52,9 +51,7 @@ class _FileEmbedState extends State<FileEmbed> {
Future<void> _open(ImageSource source) async {
final failure = await widget.opening(source, widget.attachment.name);
if (failure != null && mounted) {
showCue(context, Icons.block, failure, error: true);
}
if (failure != null && mounted) showError(context, failure);
}
@override
@ -76,10 +73,9 @@ class _FileEmbedState extends State<FileEmbed> {
style: widget.compact ? theme.textTheme.bodySmall : null,
);
final pending = store.isPending(attachment.ref)
? const Icon(
Icons.cloud_upload_outlined,
size: 18,
semanticLabel: kImageWaitingToUpload,
? const Tooltip(
message: kImageWaitingToUpload,
child: Icon(Icons.cloud_upload_outlined, size: 18),
)
: null;
final content = widget.compact
@ -108,21 +104,15 @@ class _FileEmbedState extends State<FileEmbed> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
name,
// 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),
Text(detail, style: theme.textTheme.bodySmall),
],
),
),
?pending,
],
);
// Spoken only: nothing paints this string.
final semanticLabel = 'Attached file ${attachment.name}, $detail';
return Semantics(
label: semanticLabel,
label: 'Attached file ${attachment.name}, $detail',
button: size != null,
child: Material(
color: theme.colorScheme.surfaceContainerHighest,

View File

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

View File

@ -6,11 +6,10 @@ 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';
@ -62,54 +61,53 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// User content; untitled shows nothing, not a placeholder.
if (firstLine.isNotEmpty)
Text(
firstLine,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Text(
firstLine.isEmpty ? '(empty)' : firstLine,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
NoteSheetDetails(note: widget.note),
const SizedBox(height: 16),
GlyphChips<Status>(
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
_EnumChips<Status>(
label: 'Status',
values: Status.values,
selected: {_status},
glyphOf: statusGlyph,
onTap: (s) {
labelOf: (s) => s.label,
onSelected: (s) {
setState(() => _status = s);
unawaited(_persist());
},
),
const SizedBox(height: 12),
GlyphChips<Priority>(
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
_EnumChips<Priority>(
label: 'Priority',
values: Priority.values,
selected: {_priority},
glyphOf: (p) => priorityGlyph(context, p),
onTap: (p) {
labelOf: (p) => p.label,
onSelected: (p) {
setState(() => _priority = p);
unawaited(_persist());
},
),
NoteTaskDetails(note: widget.note),
const SizedBox(height: 16),
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();
},
OutlinedButton.icon(
icon: const Icon(Icons.delete_outline),
label: const Text('Delete note'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
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();
},
),
],
),
@ -118,3 +116,42 @@ 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

@ -5,33 +5,21 @@
/// different file.
library;
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.
/// A labelled date range shown as presets plus a custom-range picker.
class DateRangeField extends StatelessWidget {
/// Creates a labelled date-range control.
const DateRangeField({
required this.icon,
required this.semanticLabel,
required this.label,
required this.from,
required this.to,
required this.onChanged,
super.key,
});
/// 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;
/// Heading shown above the control (e.g. 'Created').
final String label;
/// Start of the selected range, or null when unbounded.
final DateTime? from;
@ -64,60 +52,49 @@ class DateRangeField extends StatelessWidget {
if (picked != null) onChanged(picked.start, picked.end);
}
/// Which menu entry describes the current range.
_Preset get _current {
if (!_hasRange) return _Preset.any;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
for (final p in _Preset.dayPresets) {
final days = p.days!;
if (to == today && from == today.subtract(Duration(days: days - 1))) {
return p;
}
}
return _Preset.custom;
}
void _select(BuildContext context, _Preset preset) {
switch (preset) {
case _Preset.any:
onChanged(null, null);
case _Preset.custom:
unawaited(_pickCustom(context));
case _Preset.today || _Preset.week || _Preset.month:
_applyDays(preset.days!);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final current = _current;
// A popup menu rather than DropdownButton: re-picking "Custom…" while a
// custom range is shown must still reopen the picker.
return PopupMenuButton<_Preset>(
// 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: p.glyph()),
],
child: InputDecorator(
decoration: InputDecoration(
prefixIcon: Icon(icon, semanticLabel: semanticLabel),
isDense: true,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Icons.arrow_drop_down),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(label, style: theme.textTheme.labelLarge),
const Spacer(),
if (_hasRange)
Text(_rangeLabel(), style: theme.textTheme.bodySmall),
],
),
child: current == _Preset.custom
? Text(_rangeLabel(), style: theme.textTheme.bodyMedium)
: current.glyph(),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ActionChip(
label: const Text('Today'),
onPressed: () => _applyDays(1),
),
ActionChip(
label: const Text('7 days'),
onPressed: () => _applyDays(7),
),
ActionChip(
label: const Text('30 days'),
onPressed: () => _applyDays(30),
),
ActionChip(
label: const Text('Custom…'),
onPressed: () => _pickCustom(context),
),
if (_hasRange)
ActionChip(
avatar: const Icon(Icons.clear, size: 16),
label: const Text('Any'),
onPressed: () => onChanged(null, null),
),
],
),
],
);
}
@ -130,37 +107,3 @@ class DateRangeField extends StatelessWidget {
String _two(int n) => n.toString().padLeft(2, '0');
}
/// Entries of the range menu, in display order.
enum _Preset {
any('Any', null),
today('Today', 1),
week('7 days', 7),
month('30 days', 30),
custom('Custom…', null);
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,13 +1,13 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:todo/data/app_settings.dart';
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';
@ -52,14 +52,6 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
/// Inserts attached-image links into the editor.
final ImageAttachController _attach = ImageAttachController();
bool _copied = false;
Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: _note.text));
await HapticFeedback.lightImpact();
if (mounted) setState(() => _copied = true);
}
Future<void> _attachImages(List<Uint8List> images, {int rejected = 0}) =>
attachImages(
context,
@ -85,7 +77,12 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
_persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
Future<void> _delete() async {
if (!await confirmDeleteNote(context)) return;
final confirmed = await confirmDestructive(
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
await widget.repository.delete(_note.id);
if (mounted) Navigator.of(context).pop();
}
@ -95,25 +92,15 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
final title = noteTitle(_note.text);
return Scaffold(
appBar: AppBar(
// An untitled note shows no title rather than a written placeholder.
title: title.isEmpty ? null : Text(title),
title: Text(title.isEmpty ? '(empty)' : title),
actions: [
AttachButton(
onFiles: _attachFiles,
picking: widget.attachmentPicking,
),
IconButton(
icon: Icon(
_copied ? Icons.check : Icons.copy_all,
semanticLabel: 'Copy',
),
onPressed: _copy,
),
IconButton(
icon: const Icon(
Icons.delete_outline,
semanticLabel: 'Delete note',
),
tooltip: 'Delete note',
icon: const Icon(Icons.delete_outline),
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,12 +127,9 @@ class _NoteEditorState extends State<NoteEditor> {
case NoteEditorMode.guided:
// raw -> guided: only if the edited text still fits the template.
if (!_doc.tryAdoptGuided()) {
// The mode toggle staying on raw is the second channel.
showCue(
showError(
context,
Icons.rule,
"Text doesn't match the template — staying in raw",
error: true,
);
return;
}

View File

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

View File

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

View File

@ -16,7 +16,6 @@ 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 {
@ -67,7 +66,8 @@ class NoteEditorStepPage extends StatelessWidget {
Align(
alignment: Alignment.centerLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back, semanticLabel: 'Exit guided'),
icon: const Icon(Icons.arrow_back),
tooltip: 'Exit guided',
onPressed: onExit,
),
),
@ -96,14 +96,16 @@ class NoteEditorStepPage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 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}',
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,
),
),
const SizedBox(height: 12),
],
@ -121,10 +123,9 @@ class NoteEditorStepPage extends StatelessWidget {
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.top,
// No hintText: a hint is painted text. The section glyph
// above already names the field.
decoration: const InputDecoration(
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: section.hint,
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => onChanged(),
@ -139,22 +140,16 @@ class NoteEditorStepPage extends StatelessWidget {
if (index > 0)
TextButton(
onPressed: () => onGoToStep(index - 1),
child: const Icon(
Icons.navigate_before,
semanticLabel: 'Back',
),
child: const Text('Back'),
),
const Spacer(),
if (index < total - 1)
FilledButton(
onPressed: () => onGoToStep(index + 1),
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
child: const Text('Next'),
)
else
FilledButton(
onPressed: onExit,
child: const Icon(Icons.check, semanticLabel: 'Done'),
),
FilledButton(onPressed: onExit, child: const Text('Done')),
],
),
),

View File

@ -13,8 +13,6 @@ 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 {
@ -50,7 +48,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
@override
Widget build(BuildContext context) {
final semanticLabel = 'Step ${_step + 1} of 2';
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@ -58,24 +56,11 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
Row(
children: [
IconButton(
icon: const Icon(Icons.close, semanticLabel: 'Cancel'),
icon: const Icon(Icons.close),
tooltip: 'Cancel',
onPressed: widget.onCancel,
),
// 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,
),
],
),
),
),
Text('Step ${_step + 1} of 2', style: theme.textTheme.labelLarge),
],
),
const SizedBox(height: 8),
@ -89,14 +74,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
DropdownButtonFormField<Priority>(
initialValue: _priority,
decoration: const InputDecoration(
prefixIcon: Icon(kPriorityHeadingIcon, semanticLabel: 'Priority'),
labelText: 'Priority',
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
for (final p in Priority.values)
DropdownMenuItem(value: p, child: priorityGlyph(context, p)),
DropdownMenuItem(value: p, child: Text(p.label)),
],
onChanged: (p) {
if (p != null) setState(() => _priority = p);
@ -107,7 +92,7 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () => setState(() => _step = 1),
child: const Icon(Icons.navigate_next, semanticLabel: 'Next'),
child: const Text('Next'),
),
),
];
@ -119,14 +104,14 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
DropdownButtonFormField<String>(
initialValue: _template.id,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.description, semanticLabel: 'Template'),
labelText: 'Template',
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
items: [
for (final t in templates)
DropdownMenuItem(value: t.id, child: templateGlyph(t)),
DropdownMenuItem(value: t.id, child: Text(t.label)),
],
onChanged: (id) {
if (id == null) return;
@ -139,12 +124,12 @@ class _NoteEditorWizardState extends State<NoteEditorWizard> {
children: [
TextButton(
onPressed: () => setState(() => _step = 0),
child: const Icon(Icons.arrow_back, semanticLabel: 'Back'),
child: const Text('Back'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => widget.onStart(_priority, _template),
child: const Icon(Icons.play_arrow, semanticLabel: 'Start'),
child: const Text('Start'),
),
],
),

View File

@ -6,25 +6,6 @@ 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 {
@ -33,13 +14,9 @@ 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;
@ -50,7 +27,18 @@ class NoteGridTile extends StatelessWidget {
final VoidCallback onActions;
/// Danger for high, the accent for medium, muted for low.
Color accentFor(ColorScheme scheme) => priorityColor(note.priority, scheme);
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,
};
@override
Widget build(BuildContext context) {
@ -61,7 +49,7 @@ class NoteGridTile extends StatelessWidget {
final closed =
note.status == Status.done || note.status == Status.abandoned;
return Material(
color: selected ? scheme.secondaryContainer : scheme.surfaceContainerHigh,
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.md),
clipBehavior: Clip.antiAlias,
child: InkWell(
@ -84,55 +72,37 @@ class NoteGridTile extends StatelessWidget {
children: [
// Expanded: a big text scale shortens the title, never overflows.
Expanded(
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',
),
],
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,
),
),
),
if (note.repoName case final repo?)
Text(
repo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: cue?.copyWith(color: scheme.onSurfaceVariant),
),
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,
semanticLabel: note.status.label,
),
const SizedBox(width: AppSpacing.xs),
Flexible(
child: Text(
note.status.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: cue,
),
),
const Spacer(),
Icon(
priorityIcon(note.priority),
size: 14,
color: accentFor(scheme),
semanticLabel: note.priority.label,
Text(
note.priority.label,
maxLines: 1,
style: cue?.copyWith(color: accentFor(scheme)),
),
],
),

View File

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

View File

@ -1,86 +0,0 @@
/// 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,25 +39,12 @@ class NoteSheetDetails extends StatelessWidget {
Text(body, style: theme.bodyMedium),
],
const SizedBox(height: 12),
Wrap(
spacing: 12,
children: [
_dated(Icons.history, 'Edited', note.updatedAt, theme.bodySmall),
_dated(Icons.add, 'Created', note.createdAt, theme.bodySmall),
],
Text(
'Edited ${_stamp(note.updatedAt)}'
' · Created ${_stamp(note.createdAt)}',
style: 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,27 +12,20 @@ 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 = [
(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);
('Repo', note.repo),
('Done check', note.doneCheck),
('By', note.by),
('Evidence', note.evidence),
].where((row) => row.$2 != null);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (icon, label, value) in rows) ...[
for (final (label, value) in rows) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, semanticLabel: label),
const SizedBox(width: 8),
Expanded(child: Text(value!, style: theme.bodyMedium)),
],
),
Text(label, style: theme.labelLarge),
const SizedBox(height: 4),
Text(value!, style: theme.bodyMedium),
],
],
);

View File

@ -8,8 +8,6 @@ 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 {
@ -18,13 +16,9 @@ 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;
@ -39,19 +33,22 @@ 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)}',
].join(' · ');
return ListTile(
selected: selected,
leading: selected
? const Icon(Icons.check_circle, semanticLabel: 'Selected')
: null,
title: Text(
firstLine.isEmpty ? kEmptyTitle : firstLine,
firstLine.isEmpty ? '(empty)' : firstLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: _meta(context),
subtitle: Text(meta),
trailing: IconButton(
icon: const Icon(Icons.more_vert, semanticLabel: 'Quick actions'),
icon: const Icon(Icons.more_vert),
tooltip: 'Quick actions',
onPressed: onActions,
),
onTap: onTap,
@ -59,28 +56,14 @@ class NoteTile extends StatelessWidget {
);
}
/// 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)),
],
],
);
/// 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';
}
}
/// 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';
}
/// Bottom sheet for editing one note's priority/status or deleting it.

View File

@ -1,37 +0,0 @@
/// 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,27 +10,35 @@ 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';
/// The filter editing sheet: priority + status multi-select and Created /
/// Last-updated date ranges (one compact preset menu + custom picker). Edits a
/// Last-updated date ranges (presets + a custom range picker). Edits a
/// working copy and returns it via [Navigator.pop] on "Apply".
class FilterSheet extends StatefulWidget {
/// Creates the sheet seeded with the currently applied [initial] filter.
const FilterSheet({required this.initial, super.key});
const FilterSheet({required this.initial, this.repos = const [], super.key});
/// The filter the sheet opens with; edits are made to a working copy.
final NoteFilter initial;
/// Distinct repos of the stored repo tasks, one chip each.
final List<String> repos;
@override
State<FilterSheet> createState() => _FilterSheetState();
}
const Map<String, String> _sourceLabels = {
kIdeasSource: 'Ideas',
kRepoTasksSource: 'Repo tasks',
};
class _FilterSheetState extends State<FilterSheet> {
late Set<Priority> _priorities = {...widget.initial.priorities};
late Set<Status> _statuses = {...widget.initial.statuses};
late Set<String> _sources = {...widget.initial.sources};
late DateTime? _createdFrom = widget.initial.createdFrom;
late DateTime? _createdTo = widget.initial.createdTo;
late DateTime? _updatedFrom = widget.initial.updatedFrom;
@ -46,6 +54,7 @@ class _FilterSheetState extends State<FilterSheet> {
// Reset to the default view (hide Done/Abandoned), not an empty set,
// so "Clear all" matches the app's unfiltered baseline.
_statuses = {...kDefaultStatuses};
_sources = {...kDefaultSources};
_createdFrom = null;
_createdTo = null;
_updatedFrom = null;
@ -58,6 +67,7 @@ class _FilterSheetState extends State<FilterSheet> {
return NoteFilter(
priorities: _priorities,
statuses: _statuses,
sources: _sources,
createdFrom: _createdFrom,
createdTo: _createdTo,
updatedFrom: _updatedFrom,
@ -67,6 +77,7 @@ class _FilterSheetState extends State<FilterSheet> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
@ -82,38 +93,43 @@ class _FilterSheetState extends State<FilterSheet> {
children: [
Row(
children: [
const Icon(Icons.filter_list, semanticLabel: 'Filters'),
Text('Filters', style: theme.textTheme.titleLarge),
const Spacer(),
TextButton(
onPressed: _clearAll,
child: const Icon(Icons.clear, semanticLabel: 'Clear all'),
child: const Text('Clear all'),
),
],
),
const SizedBox(height: 8),
GlyphChips<Status>(
multi: true,
heading: kStatusHeadingIcon,
semanticLabel: 'Status',
if (widget.repos.isNotEmpty) ...[
_MultiChips<String>(
label: 'Show',
values: [kIdeasSource, kRepoTasksSource, ...widget.repos],
selected: _sources,
labelOf: (s) => _sourceLabels[s] ?? s,
onToggle: (s) => _toggle(_sources, s),
),
const SizedBox(height: 12),
],
_MultiChips<Status>(
label: 'Status',
values: Status.values,
selected: _statuses,
glyphOf: statusGlyph,
onTap: (s) => _toggle(_statuses, s),
labelOf: (s) => s.label,
onToggle: (s) => _toggle(_statuses, s),
),
const SizedBox(height: 12),
GlyphChips<Priority>(
multi: true,
heading: kPriorityHeadingIcon,
semanticLabel: 'Priority',
_MultiChips<Priority>(
label: 'Priority',
values: Priority.values,
selected: _priorities,
glyphOf: (p) => priorityGlyph(context, p),
onTap: (p) => _toggle(_priorities, p),
labelOf: (p) => p.label,
onToggle: (p) => _toggle(_priorities, p),
),
const SizedBox(height: 12),
DateRangeField(
icon: kCreatedIcon,
semanticLabel: 'Created',
label: 'Created',
from: _createdFrom,
to: _createdTo,
onChanged: (from, to) => setState(() {
@ -123,8 +139,7 @@ class _FilterSheetState extends State<FilterSheet> {
),
const SizedBox(height: 12),
DateRangeField(
icon: kUpdatedIcon,
semanticLabel: 'Last updated',
label: 'Last updated',
from: _updatedFrom,
to: _updatedTo,
onChanged: (from, to) => setState(() {
@ -135,7 +150,7 @@ class _FilterSheetState extends State<FilterSheet> {
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.of(context).pop(_build()),
child: const Icon(Icons.check, semanticLabel: 'Apply'),
child: const Text('Apply'),
),
],
),
@ -145,6 +160,45 @@ 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,13 +26,9 @@ 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;
@ -46,7 +42,6 @@ class NotesGrid extends StatelessWidget {
return NoteGridTile(
key: ValueKey(note.id),
note: note,
selected: isSelected(note.id),
onTap: () => onOpen(note),
onActions: () => onActions(note),
);

View File

@ -1,58 +0,0 @@
/// The notes list's body: phone grid or desktop list of notes.
///
/// Split out of `notes_list_screen.dart` for file size.
library;
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/note_tile.dart';
import 'package:todo/ui/notes_grid.dart';
/// Lays [notes] out as a grid below [kNotesGridMaxWidth], else a list.
class NotesListBody extends StatelessWidget {
/// Creates the body; callbacks get the tapped note.
const NotesListBody({
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;
/// Tap on a note.
final ValueChanged<Note> onOpen;
/// Long-press (or the actions button) on a note.
final ValueChanged<Note> onActions;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
? 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

@ -4,13 +4,20 @@ import 'package:todo/data/note_repository.dart';
/// The default status selection: hide completed/dropped work.
const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
/// The default source selection: plain ideas only, repo tasks hidden.
const Set<String> kDefaultSources = {kIdeasSource};
/// The notes list's unfiltered baseline, which "Clear all" resets to.
const kDefaultNoteFilter = NoteFilter(statuses: kDefaultStatuses);
const kDefaultNoteFilter = NoteFilter(
statuses: kDefaultStatuses,
sources: kDefaultSources,
);
/// Facets the user changed from [kDefaultNoteFilter], for the filter badge.
int userFacetCount(NoteFilter filter) {
var count = filter.activeCount;
if (_same(filter.statuses, kDefaultStatuses)) count -= 1;
if (_same(filter.sources, kDefaultSources)) count -= 1;
return count;
}

View File

@ -5,19 +5,14 @@ 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/note_tile.dart';
import 'package:todo/ui/notes_filter_sheet.dart';
import 'package:todo/ui/notes_list_body.dart';
import 'package:todo/ui/notes_grid.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,52 +48,31 @@ class _NotesListScreenState extends State<NotesListScreen> {
NoteSort _sort = NoteSort.priorityDesc;
/// Default view: open notes (no Done/Abandoned), ideas and repo tasks alike.
/// Default view: open ideas only (no Done/Abandoned, no repo tasks).
NoteFilter _filter = kDefaultNoteFilter;
/// 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 = _watch();
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
}
@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 = _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();
setState(() {
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
});
}
void _onSearchChanged(String value) {
@ -118,11 +92,15 @@ class _NotesListScreenState extends State<NotesListScreen> {
/// Opens the filter sheet and adopts the edited filter (text query is
/// owned by the search box, so it is preserved across the round-trip).
Future<void> _openFilters() async {
final repos = {
for (final n in await widget.repository.listNotes()) ?n.repo,
}.toList()..sort();
if (!mounted) return;
final edited = await showModalBottomSheet<NoteFilter>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => FilterSheet(initial: _filter),
builder: (_) => FilterSheet(initial: _filter, repos: repos),
);
if (edited != null) {
_filter = edited.copyWith(query: _searchController.text);
@ -135,41 +113,29 @@ class _NotesListScreenState extends State<NotesListScreen> {
final badgeCount = userFacetCount(_filter);
return Scaffold(
appBar: AppBar(
title: const Icon(Icons.note, semanticLabel: 'Notes'),
title: const Text('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>(
// Empty, not omitted: null falls back to the painted "Show menu".
tooltip: '',
icon: const Icon(Icons.sort, semanticLabel: 'Sort'),
tooltip: 'Sort',
icon: const Icon(Icons.sort),
initialValue: _sort,
onSelected: _setSort,
itemBuilder: (_) => const [
PopupMenuItem(
value: NoteSort.modifiedDesc,
child: Icon(Icons.update, semanticLabel: 'Last updated'),
child: Text('Last updated'),
),
PopupMenuItem(
value: NoteSort.createdDesc,
child: Icon(Icons.post_add, semanticLabel: 'Newest created'),
child: Text('Newest created'),
),
PopupMenuItem(
value: NoteSort.alphabetical,
child: Icon(Icons.sort_by_alpha, semanticLabel: 'Alphabetical'),
child: Text('Alphabetical'),
),
PopupMenuItem(
value: NoteSort.priorityDesc,
child: Icon(Icons.flag_outlined, semanticLabel: 'Priority'),
child: Text('Priority'),
),
],
),
@ -183,7 +149,8 @@ class _NotesListScreenState extends State<NotesListScreen> {
label: Text('$badgeCount'),
offset: const Offset(-8, 4),
child: IconButton(
icon: const Icon(Icons.filter_list, semanticLabel: 'Filter'),
tooltip: 'Filter',
icon: const Icon(Icons.filter_list),
onPressed: _openFilters,
),
),
@ -199,16 +166,15 @@ 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(
icon: const Icon(
Icons.clear,
semanticLabel: 'Clear search',
),
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
@ -225,21 +191,40 @@ class _NotesListScreenState extends State<NotesListScreen> {
final notes = snapshot.data ?? const <Note>[];
if (notes.isEmpty) {
return Center(
child: EmptyNotesCue(filtered: !_filter.isEmpty),
child: Text(
_filter.isEmpty
? 'No notes yet'
: 'No notes match these filters',
),
);
}
return NotesListBody(
notes: notes,
onOpen: (n) => _tap(n, notes),
onActions: _longPress,
isSelected: _selection.contains,
void open(Note n) =>
openNote(context, n, widget.repository, widget.appSettings);
void actions(Note n) =>
openNoteActions(context, n, widget.repository);
return LayoutBuilder(
builder: (context, box) => box.maxWidth < kNotesGridMaxWidth
? NotesGrid(
notes: notes,
onOpen: open,
onActions: actions,
)
: ListView.separated(
itemCount: notes.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => NoteTile(
key: ValueKey(notes[i].id),
note: notes[i],
onTap: () => open(notes[i]),
onActions: () => actions(notes[i]),
),
),
);
},
),
),
],
),
bottomNavigationBar: _selectionBar(),
);
}
}

View File

@ -1,43 +0,0 @@
/// 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

@ -1,128 +0,0 @@
/// 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

@ -1,97 +0,0 @@
/// 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,8 +1,6 @@
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;
@ -15,7 +13,6 @@ 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';
@ -108,8 +105,11 @@ 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 [FirebaseAuthError] from opening the
/// client (wrong account) is caught on its own, before the write.
/// 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.
Future<void> _setAdvancedMode(bool value) async {
final analytics = widget.analytics;
if (analytics != null) {
@ -167,22 +167,6 @@ 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(
@ -222,9 +206,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Icon(Icons.settings, semanticLabel: 'Settings'),
),
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@ -232,49 +214,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
valueListenable: widget.appSettings,
builder: (context, settings, _) => SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.tune, semanticLabel: _advanced),
title: const Text('Enable advanced'),
subtitle: const Text(
'Priority/status, templates, view modes, and sync details '
'in the capture screen',
),
value: settings.advancedMode,
onChanged: (value) => unawaited(_setAdvancedMode(value)),
),
),
const Divider(),
_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()),
),
),
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()),
),
CloudImagesTile(httpClient: widget.httpClient),
_link(
Icon(
Icons.cloud_sync,
color: Theme.of(context).colorScheme.outline,
semanticLabel: 'Advanced sync (GitHub): not recommended',
),
_openGitHubMirror,
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()),
),
],
),
);
}
// 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()),
);
}

View File

@ -1,49 +0,0 @@
/// 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

@ -1,30 +0,0 @@
/// 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

@ -1,55 +0,0 @@
/// 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,12 +10,6 @@ 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

@ -1,163 +0,0 @@
#!/bin/bash
# One command that takes the todo MCP server live. Idempotent; rerun any time.
#
# mcp/go_live.sh # seed, token, install, publish, verify, cloud token
# mcp/go_live.sh --verify-only # only step 5 (live MCP calls + 401 check)
# mcp/go_live.sh --dry-run # print the plan, change nothing
# mcp/go_live.sh --new-cloud-token # rotate the cloud token even if one exists
#
# Needs: python3, dart/flutter (install.sh), docker (Caddy), xclip, curl, ss.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly ROOT
readonly PORT=8735
readonly LOCAL_URL="http://127.0.0.1:${PORT}/mcp"
readonly PUBLIC_URL="https://todo.kuhy.duckdns.org/mcp"
readonly CONFIG_DIR="${HOME}/.config/todo-mcp"
readonly CRED_FILE="${CONFIG_DIR}/firebase_auth.json"
readonly TOKENS="${CONFIG_DIR}/tokens"
# Not under todo-mcp/: publish_caddy_site.sh would treat ~/.config/todo-mcp/token
# as an edge bearer gate and break the cloud token.
readonly CLIENT_TOKEN="${HOME}/.config/todo-mcp-client/token"
readonly ZSHENV="${HOME}/.zshenv"
# shellcheck disable=SC2016 # the line is written literally; zsh expands it later
readonly ZSHENV_LINE='export TODO_MCP_TOKEN="$(cat ~/.config/todo-mcp-client/token)"'
readonly SEED_DIR="${HOME}/src/utils/crdt-sync"
readonly SECRET_FILE="${HOME}/.config/crdt-sync/oauth_client_secret"
readonly CLIENT_ID="845446124781-prdoherj0v64vc6egvvcp3l0693khaur.apps.googleusercontent.com"
readonly PUBLISH="${HOME}/src/testsAndMisc/linux_configuration/features/publish_caddy_site.sh"
readonly VERIFY="${ROOT}/mcp/verify_mcp.py"
DRY_RUN=0
VERIFY_ONLY=0
NEW_CLOUD=0
say() { echo "[$1] $2"; }
die() { echo "FAIL: $*" >&2; exit 1; }
has_hash() { [[ -f "$TOKENS" ]] && awk -v e="$1" '$2 == e {f=1} END {exit !f}' "$TOKENS"; }
step_seed() {
if [[ -f "$CRED_FILE" ]]; then say 1/6 "seed: credentials exist, skipped"; return; fi
if ((DRY_RUN)); then say 1/6 "seed: would run seed_session --app todo-mcp (consent tap)"; return; fi
[[ -f "$SECRET_FILE" ]] || die "no $SECRET_FILE; run ~/src/utils/crdt-sync/tool/refresh_oauth_secret.sh --app todo-mcp"
say 1/6 "seed: sign in with Google when the browser opens"
(cd "$SEED_DIR" && python3 -m tool.seed_session --client-id "$CLIENT_ID" \
--client-secret "$(<"$SECRET_FILE")" --app todo-mcp) \
|| die "seed_session failed; rerun with -v or: tool/refresh_oauth_secret.sh --app todo-mcp"
[[ -f "$CRED_FILE" ]] || die "seed ran but $CRED_FILE is missing"
say 1/6 "seed: done"
}
step_token() {
if has_hash pc && [[ -s "$CLIENT_TOKEN" ]]; then
say 2/6 "pc token: exists, skipped"
elif ((DRY_RUN)); then
say 2/6 "pc token: would mint, store 0600 at $CLIENT_TOKEN"
else
# A hash without the raw token is unusable; minting replaces the pc line.
umask 077
mkdir -p "$(dirname "$CLIENT_TOKEN")"
"${ROOT}/mcp/mint_token.sh" pc >"$CLIENT_TOKEN"
chmod 600 "$CLIENT_TOKEN"
say 2/6 "pc token: minted"
fi
if grep -qxF "$ZSHENV_LINE" "$ZSHENV" 2>/dev/null; then
say 2/6 "zshenv: TODO_MCP_TOKEN line present"
elif ((DRY_RUN)); then
say 2/6 "zshenv: would append the TODO_MCP_TOKEN export"
else
printf '%s\n' "$ZSHENV_LINE" >>"$ZSHENV"
say 2/6 "zshenv: appended (new shells and sessions only)"
fi
}
wait_listen() {
local _
for _ in $(seq 1 60); do
if ss -ltnH "sport = :${PORT}" | grep -q .; then return 0; fi
sleep 1
done
return 1
}
step_install() {
if ((DRY_RUN)); then
say 3/6 "install: would run mcp/install.sh, enable --now todo-mcp.service, wait for :${PORT}"
return
fi
say 3/6 "install: building (dart compile, can take a minute)"
"${ROOT}/mcp/install.sh" >/dev/null
systemctl --user enable --now todo-mcp.service 2>/dev/null
wait_listen || die "nothing listens on :${PORT}; see: journalctl --user -u todo-mcp -n 30"
say 3/6 "install: unit active, :${PORT} listening"
}
step_publish() {
if ((DRY_RUN)); then
if [[ -x "$PUBLISH" || -f "$PUBLISH" ]]; then
say 4/6 "publish: would run publish_caddy_site.sh todo ${PORT}"
else
say 4/6 "publish: WOULD FAIL, $PUBLISH is missing"
fi
return
fi
[[ -f "$PUBLISH" ]] || die "missing $PUBLISH (untracked in testsAndMisc; restore it)"
bash "$PUBLISH" todo "$PORT" >/dev/null
say 4/6 "publish: https://todo.kuhy.duckdns.org -> 127.0.0.1:${PORT}"
}
verify_one() {
local url="$1" wait="$2"
python3 "$VERIFY" "$url" --token-file "$CLIENT_TOKEN" --wait "$wait"
python3 "$VERIFY" "$url" --expect-401 --wait "$wait"
}
step_verify() {
if ((DRY_RUN)); then
say 5/6 "verify: would initialize + list_tasks and expect 401 without a token on $LOCAL_URL and $PUBLIC_URL"
return
fi
[[ -s "$CLIENT_TOKEN" ]] || die "no client token at $CLIENT_TOKEN; run without --verify-only"
local out
out="$(verify_one "$LOCAL_URL" 20)" || die "local MCP check failed (journalctl --user -u todo-mcp -n 30)"
say 5/6 "verify local: ${out//$'\n'/, }"
# First public hit can wait on a fresh certificate (HTTP-01), so allow 60 s.
out="$(verify_one "$PUBLIC_URL" 60)" || die "public MCP check failed (docker logs gitea-caddy --tail 30)"
say 5/6 "verify public: ${out//$'\n'/, }"
}
step_cloud() {
if has_hash cloud && ((! NEW_CLOUD)); then
say 6/6 "cloud token: exists (rerun with --new-cloud-token to rotate)"
return
fi
if ((DRY_RUN)); then
say 6/6 "cloud token: would mint, copy to clipboard (manual: paste as cloud secret TODO_MCP_TOKEN)"
return
fi
command -v xclip >/dev/null && [[ -n "${DISPLAY:-}" ]] || die "xclip or DISPLAY missing; cloud token not minted"
"${ROOT}/mcp/mint_token.sh" cloud | xclip -selection clipboard -in
say 6/6 "MANUAL: cloud token is on the clipboard; paste it as the cloud environment secret TODO_MCP_TOKEN"
}
main() {
local arg
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
--verify-only) VERIFY_ONLY=1 ;;
--new-cloud-token) NEW_CLOUD=1 ;;
*) die "unknown option: $arg" ;;
esac
done
if ((VERIFY_ONLY)); then step_verify; return; fi
step_seed
step_token
step_install
step_publish
step_verify
step_cloud
}
main "$@"

View File

@ -1,53 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""verify_mcp.py against a fake Streamable HTTP server (no live state)."""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import verify_mcp
class _Fake(BaseHTTPRequestHandler):
def do_POST(self) -> None:
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if self.headers.get("Authorization") != "Bearer good":
self.send_response(401)
self.end_headers()
return
self.send_response(200)
self.send_header("Mcp-Session-Id", "s1")
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
if "id" in body:
reply = {"jsonrpc": "2.0", "id": body["id"], "result": {"content": []}}
self.wfile.write(f"event: message\ndata: {json.dumps(reply)}\n\n".encode())
def log_message(self, *_args: object) -> None:
return
def _serve() -> tuple[HTTPServer, str]:
server = HTTPServer(("127.0.0.1", 0), _Fake)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server, f"http://127.0.0.1:{server.server_port}/mcp"
def test_good_token_passes_and_missing_token_is_401(tmp_path: Path) -> None:
server, url = _serve()
token = tmp_path / "t"
token.write_text("good\n")
assert verify_mcp.run([url, "--token-file", str(token)]) == 0
assert verify_mcp.run([url, "--expect-401"]) == 0
server.shutdown()
def test_bad_token_fails_and_expect_401_fails_when_open(tmp_path: Path) -> None:
server, url = _serve()
token = tmp_path / "t"
token.write_text("bad\n")
assert verify_mcp.run([url, "--token-file", str(token)]) == 1
server.shutdown()

View File

@ -1,142 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Live check of the todo MCP endpoint: real initialize + list_tasks, or a 401.
verify_mcp.py <url> --token-file F [--wait S] # expects a working session
verify_mcp.py <url> --expect-401 [--wait S] # expects no-token rejection
Exit 0 on success, 1 with one reason line on stderr. Used by go_live.sh; the
token is read from a file and never printed.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
_PROTOCOL = "2025-03-26"
_HEADERS = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
class VerifyError(Exception):
"""A check failed; the message is the one line shown to kuhy."""
def _post(
url: str, body: dict[str, object], headers: dict[str, str]
) -> tuple[int, dict[str, str], str]:
"""POST JSON; return (status, lower-cased headers, body), HTTP errors included."""
request = urllib.request.Request(
url, data=json.dumps(body).encode(), headers={**_HEADERS, **headers}
)
try:
with urllib.request.urlopen(request, timeout=15) as resp:
return (
resp.status,
{k.lower(): v for k, v in resp.headers.items()},
resp.read().decode(),
)
except urllib.error.HTTPError as err:
return err.code, {k.lower(): v for k, v in err.headers.items()}, ""
def _json_of(payload: str) -> dict[str, object]:
"""Parse a plain JSON body or the first `data:` line of an SSE body."""
text = payload.strip()
if not text.startswith("{"):
lines = [x[5:].strip() for x in text.splitlines() if x.startswith("data:")]
text = next((x for x in lines if x.startswith("{")), "")
try:
parsed = json.loads(text)
except json.JSONDecodeError as exc:
msg = f"unparseable MCP reply: {payload[:80]!r}"
raise VerifyError(msg) from exc
return parsed if isinstance(parsed, dict) else {}
def check_session(url: str, token: str) -> str:
"""initialize + notifications/initialized + tools/call list_tasks."""
auth = {"Authorization": f"Bearer {token}"}
init = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": _PROTOCOL,
"capabilities": {},
"clientInfo": {"name": "go_live", "version": "1"},
},
}
status, headers, payload = _post(url, init, auth)
if status != 200:
msg = f"initialize returned HTTP {status}"
raise VerifyError(msg)
if "result" not in _json_of(payload):
msg = "initialize reply has no result"
raise VerifyError(msg)
headers_next = dict(auth)
if "mcp-session-id" in headers:
headers_next["Mcp-Session-Id"] = headers["mcp-session-id"]
_post(url, {"jsonrpc": "2.0", "method": "notifications/initialized"}, headers_next)
call = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {"name": "list_tasks", "arguments": {}},
}
status, _, payload = _post(url, call, headers_next)
if status != 200:
msg = f"list_tasks returned HTTP {status}"
raise VerifyError(msg)
result = _json_of(payload).get("result")
if not isinstance(result, dict) or result.get("isError"):
msg = f"list_tasks failed: {str(result)[:120]}"
raise VerifyError(msg)
return "list_tasks ok"
def check_rejected(url: str) -> str:
"""A request without a bearer token must be refused (401, or 403 from mcp_dart's auth hook)."""
status, _, _ = _post(url, {"jsonrpc": "2.0", "id": 1, "method": "ping"}, {})
if status not in (401, 403):
msg = f"no-token request returned HTTP {status}, expected 401 or 403"
raise VerifyError(msg)
return f"no token -> {status}"
def run(argv: list[str]) -> int:
"""Parse args, retry up to --wait seconds (cold TLS, restart), report."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("url")
parser.add_argument("--token-file", type=Path)
parser.add_argument("--expect-401", action="store_true")
parser.add_argument("--wait", type=float, default=0.0)
args = parser.parse_args(argv)
if bool(args.token_file) == args.expect_401:
parser.error("give exactly one of --token-file / --expect-401")
deadline = time.monotonic() + args.wait
while True:
try:
if args.expect_401:
print(check_rejected(args.url))
else:
token = args.token_file.read_text().strip()
print(check_session(args.url, token))
except (VerifyError, OSError) as exc:
if time.monotonic() >= deadline:
print(f"{args.url}: {exc}", file=sys.stderr)
return 1
time.sleep(2)
else:
return 0
if __name__ == "__main__":
sys.exit(run(sys.argv[1:]))

View File

@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: android_file_picker
sha256: "04c1306eda42c23ecbf449c909f4fa05c5e66fc48cf5f994d8d08b9d45e5396b"
sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
version: "2.1.2"
archive:
dependency: transitive
description:
@ -109,20 +109,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.14.0"
resolved-ref: "84343aec2dd07c38cba40690c3b5f2c84ae9cdf6"
ref: "crdt_sync_dart-v0.11.2"
resolved-ref: "1b5515fd8b1192207896c409212483aeb09704ea"
url: "https://github.com/kuhyx/utils"
source: git
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"
version: "0.11.2"
cross_file:
dependency: transitive
description:
@ -135,18 +126,10 @@ packages:
dependency: transitive
description:
name: crypto
sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
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"
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@ -155,15 +138,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
data_transfer_flutter:
dependency: transitive
description:
path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.2"
resolved-ref: eef7af4c86837ee82891493587ff3456297e5dea
url: "https://github.com/kuhyx/utils"
source: git
version: "0.2.2"
dbus:
dependency: transitive
description:
@ -176,11 +150,11 @@ packages:
dependency: "direct main"
description:
path: design_system
ref: "design_system-v0.3.1"
resolved-ref: "8dfc4fb55ff5f5091e34e118f3134138cc83b8a3"
ref: "design_system-v0.3.0"
resolved-ref: b3c9611af212880301ca79ed21f909bc4640f81f
url: "https://github.com/kuhyx/utils"
source: git
version: "0.3.1"
version: "0.3.0"
dev_build:
dependency: transitive
description:
@ -225,26 +199,26 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "079dc46b753abbddb7f77cd87e6ed9ecc62902580707514568321bdf8e050a86"
sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592"
url: "https://pub.dev"
source: hosted
version: "13.3.3"
version: "13.3.1"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: daeb0219f6279c73eb9c63ae9a566beef04ae13d59b8feba0be2a7f3b258d3d4
sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.1"
file_picker_linux:
dependency: transitive
description:
name: file_picker_linux
sha256: "9215ee8c672508cdd9a325202669f849e1baac883658626b3abed5d7777e6a4b"
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
url: "https://pub.dev"
source: hosted
version: "2.0.4"
version: "2.0.2"
file_picker_platform_interface:
dependency: transitive
description:
@ -257,10 +231,10 @@ packages:
dependency: transitive
description:
name: file_picker_web
sha256: "50749a6da2984fc29609e102ad4d80fd1d07e6895150f8e4922086167d9d45d8"
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c
url: "https://pub.dev"
source: hosted
version: "4.2.1"
version: "4.2.0"
file_selector:
dependency: "direct main"
description:
@ -416,11 +390,11 @@ packages:
dependency: "direct main"
description:
path: github_device_auth
ref: "github_device_auth-v0.4.0"
resolved-ref: "3084080d744c348a217727e26a6be838e7d5a4ff"
ref: "github_device_auth-v0.3.2"
resolved-ref: "4ccaba1bb3e495ef72d70c6a7e7d7ec98d755792"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.4.0"
version: "0.3.2"
glob:
dependency: transitive
description:
@ -429,15 +403,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_account_token:
dependency: "direct main"
description:
path: google_account_token
ref: "google_account_token-v0.1.0"
resolved-ref: db70ba204d124c6eadf9b8808cd3d3b3e70363d1
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
hooks:
dependency: transitive
description:
@ -466,10 +431,10 @@ packages:
dependency: "direct main"
description:
name: idb_shim
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
sha256: "923c92ef88569de4ec1b257b6a6d10360dc3407179f61d0c73329701a7d7b12e"
url: "https://pub.dev"
source: hosted
version: "2.11.3"
version: "2.11.1"
image:
dependency: "direct main"
description:
@ -850,10 +815,10 @@ packages:
dependency: transitive
description:
name: sembast
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
sha256: a58b26925e23071cf0f4754d8449aabe829e9a9930a872fb18e6ae4c2c88e025
url: "https://pub.dev"
source: hosted
version: "3.10.1"
version: "3.8.9+1"
share_plus:
dependency: "direct main"
description:
@ -951,10 +916,10 @@ packages:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: "9aad7a63bb8de6fe4c1e5367e8dcd8dfc89d491f44a526edf8e58a5a1fb1feed"
sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
version: "2.4.5"
sqflite_common_ffi_web:
dependency: transitive
description:
@ -1023,11 +988,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.7.2"
resolved-ref: "4567e7ca3042e683befdf6697ace1c8be9c829fc"
ref: "sync_settings_ui-v0.4.2"
resolved-ref: a4c7d29595bb9cf75f06877545249ed77d5560a9
url: "https://github.com/kuhyx/utils"
source: git
version: "0.7.2"
version: "0.4.2"
synchronized:
dependency: transitive
description:
@ -1176,10 +1141,10 @@ packages:
dependency: transitive
description:
name: windows_file_picker
sha256: cd1be37ada0abcfaa55bddb7410e8515844e35cd77669c35d9a28a15722299d8
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.0.1"
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.87+1
version: 1.0.51+1
environment:
sdk: ^3.12.2
@ -33,13 +33,8 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.14.0
ref: crdt_sync_dart-v0.11.2
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.
@ -50,9 +45,9 @@ dependencies:
design_system:
git:
url: https://github.com/kuhyx/utils
ref: design_system-v0.3.1
ref: design_system-v0.3.0
path: design_system
file_picker: 13.3.3
file_picker: 13.3.1
file_selector: 1.1.0
flutter:
sdk: flutter
@ -64,17 +59,10 @@ dependencies:
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.4.0
ref: github_device_auth-v0.3.2
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/.
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
http: 1.6.0
idb_shim: 2.11.3
idb_shim: 2.11.1
# 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
@ -90,7 +78,7 @@ dependencies:
path_provider: 2.1.6
share_plus: 13.3.1
shared_preferences: 2.5.6
sqflite_common_ffi: 2.4.7
sqflite_common_ffi: 2.4.5
# 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
@ -102,7 +90,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.7.2
ref: sync_settings_ui-v0.4.2
path: sync_settings_ui
url_launcher: 6.3.3
uuid: 4.6.0

View File

@ -1,183 +0,0 @@
#!/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

@ -1,12 +0,0 @@
#!/bin/bash
# Shim: forward to the shared todo git hooks in ~/src/utils (silent if absent).
set -euo pipefail
readonly SHARED="${UTILS_ROOT:-$HOME/src/utils}/scripts/todo_hooks.sh"
main() {
[[ -f "$SHARED" ]] || exit 0
exec bash "$SHARED" "$@"
}
main "$@"

View File

@ -1,10 +1,8 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_account_token/google_account_token.dart'
show kGoogleAccountTokenChannel;
import 'package:todo/sync/account_token.dart';
import 'package:todo/sync/google_sign_in_backend.dart';
void main() {
@ -14,44 +12,47 @@ void main() {
MethodCall? seen;
void fakeChannel(Future<Object?> Function(MethodCall) handler) {
messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, (call) {
messenger.setMockMethodCallHandler(kAccountTokenChannel, (call) {
seen = call;
return handler(call);
});
}
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
tearDown(() {
debugDefaultTargetPlatformOverride = null;
seen = null;
messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, null);
messenger.setMockMethodCallHandler(kAccountTokenChannel, null);
});
group('googleIdToken', () {
test('goes through the shared plugin channel by default', () async {
fakeChannel((_) async => 'jwt-2');
expect(await googleIdToken(), 'jwt-2');
group('accountManagerIdToken', () {
test('asks the platform for a token for the web client', () async {
fakeChannel((_) async => 'jwt');
final token = await accountManagerIdToken(serverClientId: 'web-id');
expect(token, 'jwt');
expect(seen?.method, 'idToken');
expect(seen?.arguments, {'serverClientId': kServerClientId});
expect(seen?.arguments, {'serverClientId': 'web-id'});
});
test('a cancelled chooser is null, not an error', () async {
fakeChannel((_) async => null);
expect(await googleIdToken(), isNull);
expect(await accountManagerIdToken(serverClientId: 'x'), isNull);
});
test('a platform failure is null, not a throw', () async {
fakeChannel((_) async => throw PlatformException(code: 'auth_failed'));
expect(await accountManagerIdToken(serverClientId: 'x'), isNull);
});
});
test('sign-in comes from the shared plugin, not a local copy', () {
group('googleIdToken', () {
test('goes through the AccountManager channel by default', () async {
fakeChannel((_) async => 'jwt-2');
expect(await googleIdToken(), 'jwt-2');
expect(seen?.arguments, {'serverClientId': kServerClientId});
});
});
test('no proprietary sign-in dependency is declared', () {
final pubspec = File('pubspec.yaml').readAsStringSync();
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(),
isFalse,
);
expect(File('lib/sync/account_token.dart').existsSync(), isFalse);
});
}

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