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
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 15:57:03 +01:00
parent 4c77dd5d2e
commit 6bd2923f0a
5 changed files with 138 additions and 61 deletions

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

@ -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.70+1
environment:
sdk: ^3.12.2

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 {