mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:07:02 +01:00
Compare commits
4 Commits
v1.0.69-bu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a30e7b34a9 | |||
| 17d6d2a533 | |||
| 4e8ccd6bfd | |||
| 6bd2923f0a |
@ -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
|
||||
|
||||
@ -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': (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
44
pubspec.lock
44
pubspec.lock
@ -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:
|
||||
|
||||
10
pubspec.yaml
10
pubspec.yaml
@ -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
|
||||
|
||||
@ -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),
|
||||
);
|
||||
|
||||
83
test/cli/cli_set_text_test.dart
Normal file
83
test/cli/cli_set_text_test.dart
Normal 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
83
test/log_watch_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user