diff --git a/lib/data/log_watch.dart b/lib/data/log_watch.dart index be2b150..ba8091f 100644 --- a/lib/data/log_watch.dart +++ b/lib/data/log_watch.dart @@ -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 watchLog(Stream changes, T Function() compute) { StreamSubscription? sub; + Timer? pending; late final StreamController controller; controller = StreamController( 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; } diff --git a/lib/ui/app_route.dart b/lib/ui/app_route.dart index 24aa534..ff373f8 100644 --- a/lib/ui/app_route.dart +++ b/lib/ui/app_route.dart @@ -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 appRoute(BuildContext context, WidgetBuilder builder) => - _AppPageRoute( - 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 appRoute(BuildContext _, WidgetBuilder builder) => + _AppPageRoute(builder: builder); class _AppPageRoute extends MaterialPageRoute { - _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 extends MaterialPageRoute { Animation animation, Animation secondaryAnimation, Widget child, - ) { - final enter = CurvedAnimation( - parent: animation, - curve: AppCurve.decelerate, - reverseCurve: AppCurve.accelerate, - ); - return FadeTransition( - opacity: enter, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.04), - end: Offset.zero, - ).animate(enter), - child: child, - ), - ); - } + ) => child; } diff --git a/pubspec.yaml b/pubspec.yaml index be36ec2..3235a98 100644 --- a/pubspec.yaml +++ b/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.70+1 environment: sdk: ^3.12.2 diff --git a/test/log_watch_test.dart b/test/log_watch_test.dart new file mode 100644 index 0000000..ea34d33 --- /dev/null +++ b/test/log_watch_test.dart @@ -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 changes; + late int computes; + + setUp(() { + changes = StreamController.broadcast(sync: true); + computes = 0; + }); + tearDown(() => changes.close()); + + Stream watch() => watchLog(changes.stream, () => ++computes); + + test('the seed is emitted at once on listen', () async { + final seen = []; + 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 = []; + 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 = []; + 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 = []; + final b = []; + 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); + }); +} diff --git a/test/motion/motion_tokens_test.dart b/test/motion/motion_tokens_test.dart index abfb46c..63d3937 100644 --- a/test/motion/motion_tokens_test.dart +++ b/test/motion/motion_tokens_test.dart @@ -13,7 +13,7 @@ double _barValue(WidgetTester tester) => tester .widget(find.byType(LinearProgressIndicator)) .value!; -Future _pushPage(WidgetTester tester) async { +Future> _pushPage(WidgetTester tester) async { late BuildContext ctx; await tester.pumpWidget( MaterialApp( @@ -25,12 +25,10 @@ Future _pushPage(WidgetTester tester) async { ), ), ); - unawaited( - Navigator.of( - ctx, - ).push(appRoute(ctx, (_) => const Scaffold(body: Text('page')))), - ); + final route = appRoute(ctx, (_) => const Scaffold(body: Text('page'))); + unawaited(Navigator.of(ctx).push(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; + 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( - 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(page)) { + expect(f.opacity.value, 1); + } }); testWidgets('remove-animations: the page is there at once', (tester) async {