mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
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
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:
parent
4c77dd5d2e
commit
6bd2923f0a
@ -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;
|
||||
}
|
||||
|
||||
@ -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
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