Compare commits

...

4 Commits

Author SHA1 Message Date
a30e7b34a9 CLI: --set-text replaces a note's text
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Keeps every other field; text from --text or stdin, empty refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:56:23 +01:00
17d6d2a533 Bump file_picker, idb_shim, sqflite_common_ffi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:56:22 +01:00
4e8ccd6bfd Lean gradle.properties for warm daemon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 17:51:44 +01:00
6bd2923f0a Instant page routes; coalesce watch re-emits
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
2026-11-10 15:57:03 +01:00
14 changed files with 281 additions and 94 deletions

View File

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

View File

@ -63,6 +63,9 @@ class CliArgs {
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();
@ -74,7 +77,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, --close and '
'--list, --search, --get, --delete, --set-status, --set-text, '
'--close and '
'--backfill-created-by are mutually exclusive',
);
}
@ -110,7 +114,8 @@ class CliArgs {
/// The selected mode.
final CliMode mode;
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
/// Note text from `--text`; null means "read stdin" in [CliMode.add] and
/// [CliMode.setText].
final String? text;
/// Full id or 8+ character prefix of the note an id mode acts on.
@ -146,7 +151,7 @@ class CliArgs {
static const _adding = 'when adding a note';
static const Map<String, (String, Set<CliMode>)> _scopes = {
'--text': (_adding, {CliMode.add}),
'--text': ('$_adding or to --set-text', {CliMode.add, CliMode.setText}),
'--done-check': (_adding, {CliMode.add}),
'--by': (_adding, {CliMode.add}),
'--repo': (

View File

@ -136,6 +136,15 @@ class CliCommands {
return 0;
}
/// Replaces a note's whole text (title is its first line) and pushes it.
Future<int> setText(String ref, String text) async {
final note = await _pullAndFind(ref);
await _save(note.copyWith(text: text));
if (!await _push('text change')) return 1;
_out.writeln('${note.id}\ttext');
return 0;
}
/// Marks a note done with [evidence], keeping it, and pushes it.
Future<int> close(String ref, String evidence) async {
final note = await _pullAndFind(ref);

View File

@ -18,6 +18,9 @@ enum CliMode {
/// 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,

View File

@ -54,7 +54,7 @@ Future<int> runCli(
return 0;
}
String? text;
if (args.mode == CliMode.add) {
if (args.mode == CliMode.add || args.mode == CliMode.setText) {
text = args.text ?? await readStdin();
if (text.trim().isEmpty) {
err.writeln('error: empty note (pass --text or pipe text on stdin)');
@ -79,6 +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),

View File

@ -22,6 +22,8 @@ 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

View File

@ -1,22 +1,38 @@
/// A seeded stream over the note log's change events.
/// A seeded, coalesced stream over the note log's change events.
library;
import 'dart:async';
/// A stream that emits `compute()` synchronously when first listened to (the
/// seed), then again on every event of [changes]. 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.
/// 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((_) => controller.add(compute()));
sub = changes.listen((_) {
pending ??= Timer(Duration.zero, () {
pending = null;
controller.add(compute());
});
});
},
onCancel: () {
pending?.cancel();
pending = null;
return sub?.cancel();
},
onCancel: () => sub?.cancel(),
);
return controller.stream;
}

View File

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

View File

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

View File

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

View File

@ -32,7 +32,11 @@ class CliHarness {
String dir(String name) => '${tmp.path}/$name';
Future<int> run(List<String> argv, {SessionOpener? opener}) async {
Future<int> run(
List<String> argv, {
SessionOpener? opener,
String stdin = '',
}) async {
out.clear();
err.clear();
return runCli(
@ -40,7 +44,7 @@ class CliHarness {
env: env,
out: out,
err: err,
readStdin: () async => '',
readStdin: () async => stdin,
open:
opener ?? (d, {required seedCredentials}) => open(d, seedCredentials),
);

View File

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

83
test/log_watch_test.dart Normal file
View File

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

View File

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