From 2ada5a2b7ed209e70c0cf6b129df64410b817194 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Tue, 29 Sep 2026 20:32:56 +0800 Subject: [PATCH 1/4] feat(home): slide between shell branches instead of switching instantly The bottom navigation tabs used StatefulShellRoute.indexedStack, which swaps the branch instantly. Host the branches in a PageView (AnimatedBranchPageView) instead so the switch slides horizontally; every branch keeps its own navigator and page state because the pages stay mounted. --- .../widgets/animated_branch_page_view.dart | 47 +++++++++++++++++++ lib/features/home/widgets/widgets.dart | 1 + lib/routes/app_routes.dart | 6 ++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 lib/features/home/widgets/animated_branch_page_view.dart diff --git a/lib/features/home/widgets/animated_branch_page_view.dart b/lib/features/home/widgets/animated_branch_page_view.dart new file mode 100644 index 00000000..e44c7b89 --- /dev/null +++ b/lib/features/home/widgets/animated_branch_page_view.dart @@ -0,0 +1,47 @@ +part of 'widgets.dart'; + +/// Duration of the branch switch slide. +const _branchSlideDuration = Duration(milliseconds: 300); + +/// Hosts the shell branches in a [PageView], so switching a bottom navigation tab slides horizontally instead of +/// jumping. Every branch keeps its own navigator and state because the pages stay mounted. +class AnimatedBranchPageView extends StatefulWidget { + /// Constructor. + const AnimatedBranchPageView({required this.navigationShell, required this.children, super.key}); + + /// The shell that owns the branches and reports the current one. + final StatefulNavigationShell navigationShell; + + /// The branch navigators, in branch order. + final List children; + + @override + State createState() => _AnimatedBranchPageViewState(); +} + +class _AnimatedBranchPageViewState extends State { + late final PageController _controller = PageController(initialPage: widget.navigationShell.currentIndex); + + @override + void didUpdateWidget(AnimatedBranchPageView oldWidget) { + super.didUpdateWidget(oldWidget); + final index = widget.navigationShell.currentIndex; + if (index != oldWidget.navigationShell.currentIndex) { + unawaited(_controller.animateToPage(index, duration: _branchSlideDuration, curve: Curves.easeInOut)); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => PageView( + controller: _controller, + // The navigation bar drives the tab. Not swipeable, so the scrollables inside the branches keep their own gestures. + physics: const NeverScrollableScrollPhysics(), + children: widget.children, + ); +} diff --git a/lib/features/home/widgets/widgets.dart b/lib/features/home/widgets/widgets.dart index f7f2a64d..7a5f06f5 100644 --- a/lib/features/home/widgets/widgets.dart +++ b/lib/features/home/widgets/widgets.dart @@ -14,6 +14,7 @@ import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/widgets/app_surface.dart'; +part 'animated_branch_page_view.dart'; part 'home_navigation_bar.dart'; part 'home_navigation_drawer.dart'; part 'home_navigation_rail.dart'; diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index db47d592..1ba2533f 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -17,6 +17,7 @@ import 'package:tsdm_client/features/forum/view/forum_group_page.dart'; import 'package:tsdm_client/features/forum/view/forum_page.dart'; import 'package:tsdm_client/features/friend/view/friend_page.dart'; import 'package:tsdm_client/features/home/view/home_page.dart'; +import 'package:tsdm_client/features/home/widgets/widgets.dart'; import 'package:tsdm_client/features/homepage/view/homepage_page.dart'; import 'package:tsdm_client/features/image/view/image_detail_page.dart'; import 'package:tsdm_client/features/latest_thread/view/latest_thread_page.dart'; @@ -79,12 +80,15 @@ final router = GoRouter( /// All named routes in app. final List _appRoutes = [ - StatefulShellRoute.indexedStack( + StatefulShellRoute( builder: (context, router, navigator) { final hideNavigationBarPages = [ScreenPaths.settingsThreadAppearance.fullPath]; // Partial global singleton page here. return HomePage(showNavigationBar: !hideNavigationBarPages.contains(router.fullPath), child: navigator); }, + // Slide horizontally between the shell branches instead of switching instantly. + navigatorContainerBuilder: (context, navigationShell, children) => + AnimatedBranchPageView(navigationShell: navigationShell, children: children), branches: [ StatefulShellBranch( routes: [AppRoute(path: ScreenPaths.homepage, builder: (_) => const HomepagePage())], From 6b9b8303b2fe24bd03a102f4bd452990bd4ac07e Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Tue, 29 Sep 2026 20:44:58 +0800 Subject: [PATCH 2/4] test: shell branch slide keeps every branch mounted Three-branch shell, like the app: the switch animates the PageView, and a branch switched away from (even to the far one) keeps its page state instead of being rebuilt. --- .../test_145_home_branch_slide_test.dart | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 test/regression/test_145_home_branch_slide_test.dart diff --git a/test/regression/test_145_home_branch_slide_test.dart b/test/regression/test_145_home_branch_slide_test.dart new file mode 100644 index 00000000..c48470d5 --- /dev/null +++ b/test/regression/test_145_home_branch_slide_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/features/home/widgets/widgets.dart'; + +/// Regression test of the shell branch slide (#145). +/// +/// The bottom navigation tabs switch through [AnimatedBranchPageView], which hosts the branches in a [PageView]: the +/// switch animates instead of jumping, and every branch has to stay mounted so its page state survives — the +/// `indexedStack` route it replaced kept all of them alive. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// Pump a two-branch shell that uses the same container as the app. + Future pumpShell(WidgetTester tester) async { + final router = GoRouter( + initialLocation: '/a', + routes: [ + StatefulShellRoute( + builder: (context, state, navigationShell) => Scaffold( + body: navigationShell, + bottomNavigationBar: BottomNavigationBar( + currentIndex: navigationShell.currentIndex, + onTap: (index) => navigationShell.goBranch(index), + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home_outlined), label: 'tab-a'), + BottomNavigationBarItem(icon: Icon(Icons.star_outline), label: 'tab-b'), + BottomNavigationBarItem(icon: Icon(Icons.favorite_outline), label: 'tab-c'), + ], + ), + ), + navigatorContainerBuilder: (context, navigationShell, children) => + AnimatedBranchPageView(navigationShell: navigationShell, children: children), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute(path: '/a', builder: (_, _) => const _CounterPage(label: 'a', key: ValueKey('page-a'))), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute(path: '/b', builder: (_, _) => const _CounterPage(label: 'b', key: ValueKey('page-b'))), + ], + ), + // Three branches, like the app: switching to the far one is what a PageView would drop when it does not + // keep its pages alive. + StatefulShellBranch( + routes: [ + GoRoute(path: '/c', builder: (_, _) => const _CounterPage(label: 'c', key: ValueKey('page-c'))), + ], + ), + ], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + } + + /// Tap the "+1" button of the branch [label]. + Future tapPlus(WidgetTester tester, String label) async { + await tester.tap( + find.descendant(of: find.byKey(ValueKey('page-$label')), matching: find.text('plus')), + ); + await tester.pumpAndSettle(); + } + + testWidgets('switching a branch slides instead of jumping', (tester) async { + await pumpShell(tester); + final pageView = tester.widget(find.byType(PageView)); + expect(pageView.physics, isA()); + expect(pageView.controller!.page, 0); + + await tester.tap(find.text('tab-c')); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(PageView)).controller!.page, 2); + expect(find.text('c: 0'), findsOneWidget); + }); + + testWidgets('a branch keeps its state while a far branch is shown', (tester) async { + await pumpShell(tester); + await tapPlus(tester, 'a'); + expect(find.text('a: 1'), findsOneWidget); + + await tester.tap(find.text('tab-c')); + await tester.pumpAndSettle(); + expect(find.text('c: 0'), findsOneWidget); + + await tester.tap(find.text('tab-a')); + await tester.pumpAndSettle(); + + // The first branch must not have been rebuilt from scratch by the slide. + expect(find.text('a: 1'), findsOneWidget); + }); +} + +/// A branch page with local state, so a lost branch shows up as a reset counter. +class _CounterPage extends StatefulWidget { + const _CounterPage({required this.label, super.key}); + + final String label; + + @override + State<_CounterPage> createState() => _CounterPageState(); +} + +class _CounterPageState extends State<_CounterPage> { + int _count = 0; + + @override + Widget build(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('${widget.label}: $_count'), + FilledButton(onPressed: () => setState(() => _count++), child: const Text('plus')), + ], + ); +} From ac783b9776bae01574dd84d7982f7f4d14263e9d Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Tue, 29 Sep 2026 22:11:40 +0800 Subject: [PATCH 3/4] fix(home): stop hidden branches animating and honour reduced motion - Every branch stays mounted in the PageView, so the ones that are off screen have to be muted by hand; the indexedStack container this replaced wrapped them in an Offstage with a disabled TickerMode. - The shell reports the new branch before the slide starts, so the branch being slid to is the current one and keeps animating while it moves into view. --- .../widgets/animated_branch_page_view.dart | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/features/home/widgets/animated_branch_page_view.dart b/lib/features/home/widgets/animated_branch_page_view.dart index e44c7b89..fef84f22 100644 --- a/lib/features/home/widgets/animated_branch_page_view.dart +++ b/lib/features/home/widgets/animated_branch_page_view.dart @@ -26,9 +26,15 @@ class _AnimatedBranchPageViewState extends State { void didUpdateWidget(AnimatedBranchPageView oldWidget) { super.didUpdateWidget(oldWidget); final index = widget.navigationShell.currentIndex; - if (index != oldWidget.navigationShell.currentIndex) { - unawaited(_controller.animateToPage(index, duration: _branchSlideDuration, curve: Curves.easeInOut)); + if (index == oldWidget.navigationShell.currentIndex) { + return; } + // Respect the system "reduce motion" setting: the switch is instant instead of a slide. + if (MediaQuery.disableAnimationsOf(context)) { + _controller.jumpToPage(index); + return; + } + unawaited(_controller.animateToPage(index, duration: _branchSlideDuration, curve: Curves.easeInOut)); } @override @@ -38,10 +44,21 @@ class _AnimatedBranchPageViewState extends State { } @override - Widget build(BuildContext context) => PageView( - controller: _controller, - // The navigation bar drives the tab. Not swipeable, so the scrollables inside the branches keep their own gestures. - physics: const NeverScrollableScrollPhysics(), - children: widget.children, - ); + Widget build(BuildContext context) { + // The shell reports the new branch before the slide starts, so the branch being slid to is the current one as + // well; deciding by `_controller.page` would freeze the target until the slide is over. + final currentIndex = widget.navigationShell.currentIndex; + return PageView( + controller: _controller, + // The navigation bar drives the tab. Not swipeable, so the scrollables inside the branches keep their own gestures. + physics: const NeverScrollableScrollPhysics(), + // The pages stay mounted, so a branch that is off screen has to stop its own animations: the `indexedStack` + // container this replaced did that for us by wrapping every hidden branch in an `Offstage` with a disabled + // `TickerMode`. + children: [ + for (final (index, child) in widget.children.indexed) + TickerMode(enabled: index == currentIndex, child: child), + ], + ); + } } From ad511c27dfaf563da08ad1fc81fa7cf12aca1a75 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Tue, 29 Sep 2026 22:11:40 +0800 Subject: [PATCH 4/4] test: cover hidden branch tickers and reduced motion - The slide test file collided with test_145_poll_create_parser_test.dart, so it is numbered after the latest test now. - One case counts the frames of an endless animation in every branch: the one left off screen must stop, the slide target must keep running while it moves in. - One case asserts the reduced motion setting switches within a single frame, while the default still slides. --- .../test_145_home_branch_slide_test.dart | 120 ---------- .../test_175_home_branch_slide_test.dart | 222 ++++++++++++++++++ 2 files changed, 222 insertions(+), 120 deletions(-) delete mode 100644 test/regression/test_145_home_branch_slide_test.dart create mode 100644 test/regression/test_175_home_branch_slide_test.dart diff --git a/test/regression/test_145_home_branch_slide_test.dart b/test/regression/test_145_home_branch_slide_test.dart deleted file mode 100644 index c48470d5..00000000 --- a/test/regression/test_145_home_branch_slide_test.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; -import 'package:tsdm_client/features/home/widgets/widgets.dart'; - -/// Regression test of the shell branch slide (#145). -/// -/// The bottom navigation tabs switch through [AnimatedBranchPageView], which hosts the branches in a [PageView]: the -/// switch animates instead of jumping, and every branch has to stay mounted so its page state survives — the -/// `indexedStack` route it replaced kept all of them alive. -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - /// Pump a two-branch shell that uses the same container as the app. - Future pumpShell(WidgetTester tester) async { - final router = GoRouter( - initialLocation: '/a', - routes: [ - StatefulShellRoute( - builder: (context, state, navigationShell) => Scaffold( - body: navigationShell, - bottomNavigationBar: BottomNavigationBar( - currentIndex: navigationShell.currentIndex, - onTap: (index) => navigationShell.goBranch(index), - items: const [ - BottomNavigationBarItem(icon: Icon(Icons.home_outlined), label: 'tab-a'), - BottomNavigationBarItem(icon: Icon(Icons.star_outline), label: 'tab-b'), - BottomNavigationBarItem(icon: Icon(Icons.favorite_outline), label: 'tab-c'), - ], - ), - ), - navigatorContainerBuilder: (context, navigationShell, children) => - AnimatedBranchPageView(navigationShell: navigationShell, children: children), - branches: [ - StatefulShellBranch( - routes: [ - GoRoute(path: '/a', builder: (_, _) => const _CounterPage(label: 'a', key: ValueKey('page-a'))), - ], - ), - StatefulShellBranch( - routes: [ - GoRoute(path: '/b', builder: (_, _) => const _CounterPage(label: 'b', key: ValueKey('page-b'))), - ], - ), - // Three branches, like the app: switching to the far one is what a PageView would drop when it does not - // keep its pages alive. - StatefulShellBranch( - routes: [ - GoRoute(path: '/c', builder: (_, _) => const _CounterPage(label: 'c', key: ValueKey('page-c'))), - ], - ), - ], - ), - ], - ); - addTearDown(router.dispose); - await tester.pumpWidget(MaterialApp.router(routerConfig: router)); - await tester.pumpAndSettle(); - } - - /// Tap the "+1" button of the branch [label]. - Future tapPlus(WidgetTester tester, String label) async { - await tester.tap( - find.descendant(of: find.byKey(ValueKey('page-$label')), matching: find.text('plus')), - ); - await tester.pumpAndSettle(); - } - - testWidgets('switching a branch slides instead of jumping', (tester) async { - await pumpShell(tester); - final pageView = tester.widget(find.byType(PageView)); - expect(pageView.physics, isA()); - expect(pageView.controller!.page, 0); - - await tester.tap(find.text('tab-c')); - await tester.pumpAndSettle(); - - expect(tester.widget(find.byType(PageView)).controller!.page, 2); - expect(find.text('c: 0'), findsOneWidget); - }); - - testWidgets('a branch keeps its state while a far branch is shown', (tester) async { - await pumpShell(tester); - await tapPlus(tester, 'a'); - expect(find.text('a: 1'), findsOneWidget); - - await tester.tap(find.text('tab-c')); - await tester.pumpAndSettle(); - expect(find.text('c: 0'), findsOneWidget); - - await tester.tap(find.text('tab-a')); - await tester.pumpAndSettle(); - - // The first branch must not have been rebuilt from scratch by the slide. - expect(find.text('a: 1'), findsOneWidget); - }); -} - -/// A branch page with local state, so a lost branch shows up as a reset counter. -class _CounterPage extends StatefulWidget { - const _CounterPage({required this.label, super.key}); - - final String label; - - @override - State<_CounterPage> createState() => _CounterPageState(); -} - -class _CounterPageState extends State<_CounterPage> { - int _count = 0; - - @override - Widget build(BuildContext context) => Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('${widget.label}: $_count'), - FilledButton(onPressed: () => setState(() => _count++), child: const Text('plus')), - ], - ); -} diff --git a/test/regression/test_175_home_branch_slide_test.dart b/test/regression/test_175_home_branch_slide_test.dart new file mode 100644 index 00000000..3d92a459 --- /dev/null +++ b/test/regression/test_175_home_branch_slide_test.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/features/home/widgets/widgets.dart'; + +/// Regression test of the shell branch slide (#145). +/// +/// The bottom navigation tabs switch through [AnimatedBranchPageView], which hosts the branches in a [PageView]: the +/// switch animates instead of jumping, and every branch has to stay mounted so its page state survives — the +/// `indexedStack` route it replaced kept all of them alive. Staying mounted also means the hidden branches have to be +/// muted by hand, otherwise their progress indicators keep animating behind the visible one. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// Pump a three-branch shell that uses the same container as the app. + /// + /// With [disableAnimations] the container receives the system "reduce motion" setting through a [MediaQuery], like + /// the app does when the platform reports it. [ticks] makes every branch run an endless animation and counts its + /// frames there; the shell is not settled then, because an endless animation never settles. + Future pumpShell(WidgetTester tester, {bool disableAnimations = false, Map? ticks}) async { + final router = GoRouter( + initialLocation: '/a', + routes: [ + StatefulShellRoute( + builder: (context, state, navigationShell) => Scaffold( + body: navigationShell, + bottomNavigationBar: BottomNavigationBar( + currentIndex: navigationShell.currentIndex, + onTap: (index) => navigationShell.goBranch(index), + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home_outlined), label: 'tab-a'), + BottomNavigationBarItem(icon: Icon(Icons.star_outline), label: 'tab-b'), + BottomNavigationBarItem(icon: Icon(Icons.favorite_outline), label: 'tab-c'), + ], + ), + ), + navigatorContainerBuilder: (context, navigationShell, children) { + final container = AnimatedBranchPageView(navigationShell: navigationShell, children: children); + if (!disableAnimations) { + return container; + } + return MediaQuery(data: MediaQuery.of(context).copyWith(disableAnimations: true), child: container); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/a', + builder: (_, _) => _CounterPage(label: 'a', ticks: ticks, key: const ValueKey('page-a')), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/b', + builder: (_, _) => _CounterPage(label: 'b', ticks: ticks, key: const ValueKey('page-b')), + ), + ], + ), + // Three branches, like the app: switching to the far one is what a PageView would drop when it does not + // keep its pages alive. + StatefulShellBranch( + routes: [ + GoRoute( + path: '/c', + builder: (_, _) => _CounterPage(label: 'c', ticks: ticks, key: const ValueKey('page-c')), + ), + ], + ), + ], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + if (ticks == null) { + await tester.pumpAndSettle(); + return; + } + // The endless animations never settle, so let the router build and the first branch mount instead. + await tester.pump(); + await tester.pump(); + } + + /// Tap the "+1" button of the branch [label]. + Future tapPlus(WidgetTester tester, String label) async { + await tester.tap( + find.descendant(of: find.byKey(ValueKey('page-$label')), matching: find.text('plus')), + ); + await tester.pumpAndSettle(); + } + + /// Current page of the branch container. + double? currentPage(WidgetTester tester) => tester.widget(find.byType(PageView)).controller!.page; + + testWidgets('switching a branch slides instead of jumping', (tester) async { + await pumpShell(tester); + final pageView = tester.widget(find.byType(PageView)); + expect(pageView.physics, isA()); + expect(pageView.controller!.page, 0); + + await tester.tap(find.text('tab-c')); + await tester.pumpAndSettle(); + + expect(currentPage(tester), 2); + expect(find.text('c: 0'), findsOneWidget); + }); + + testWidgets('a branch keeps its state while a far branch is shown', (tester) async { + await pumpShell(tester); + await tapPlus(tester, 'a'); + expect(find.text('a: 1'), findsOneWidget); + + await tester.tap(find.text('tab-c')); + await tester.pumpAndSettle(); + expect(find.text('c: 0'), findsOneWidget); + + await tester.tap(find.text('tab-a')); + await tester.pumpAndSettle(); + + // The first branch must not have been rebuilt from scratch by the slide. + expect(find.text('a: 1'), findsOneWidget); + }); + + testWidgets('a branch stops animating while it is off screen', (tester) async { + final ticks = {}; + await pumpShell(tester, ticks: ticks); + + // The branch on screen animates. + final firstVisible = ticks['a'] ?? 0; + await tester.pump(const Duration(milliseconds: 100)); + expect(ticks['a'], greaterThan(firstVisible)); + + await tester.tap(find.text('tab-b')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + // The branch moving into view mounts halfway through the slide; its animation must run from there on. + final incoming = ticks['b'] ?? 0; + await tester.pump(const Duration(milliseconds: 50)); + expect(ticks['b'], greaterThan(incoming), reason: 'the slide target must animate while it moves in'); + + // Let the slide finish, then watch both branches for another stretch. + await tester.pump(const Duration(milliseconds: 200)); + final hidden = ticks['a'] ?? 0; + final shown = ticks['b'] ?? 0; + await tester.pump(const Duration(milliseconds: 300)); + expect(ticks['a'], hidden, reason: 'the branch that is off screen must not keep animating'); + expect(ticks['b'], greaterThan(shown)); + }); + + testWidgets('the reduced motion setting jumps instead of sliding', (tester) async { + await pumpShell(tester, disableAnimations: true); + + await tester.tap(find.text('tab-c')); + // A single frame is enough: the switch does not wait for a slide. + await tester.pump(); + expect(currentPage(tester), 2); + expect(find.text('c: 0'), findsOneWidget); + + // Animations on: the same single frame leaves the slide in flight, so the case above really covers the setting. + await pumpShell(tester); + await tester.tap(find.text('tab-c')); + await tester.pump(); + expect(currentPage(tester), lessThan(2)); + + await tester.pumpAndSettle(); + expect(currentPage(tester), 2); + }); +} + +/// A branch page with local state, so a lost branch shows up as a reset counter. +/// +/// With [ticks] it also runs an endless animation that counts its own frames there, which is how a test tells whether +/// the branch is allowed to animate. +class _CounterPage extends StatefulWidget { + const _CounterPage({required this.label, this.ticks, super.key}); + + final String label; + + /// Frame counter of the endless animation, shared by every branch; null to keep the page still. + final Map? ticks; + + @override + State<_CounterPage> createState() => _CounterPageState(); +} + +class _CounterPageState extends State<_CounterPage> with SingleTickerProviderStateMixin { + int _count = 0; + + AnimationController? _spinner; + + @override + void initState() { + super.initState(); + final ticks = widget.ticks; + if (ticks == null) { + return; + } + final spinner = AnimationController(vsync: this, duration: const Duration(seconds: 1)) + ..addListener(() => ticks[widget.label] = (ticks[widget.label] ?? 0) + 1); + _spinner = spinner; + unawaited(spinner.repeat()); + } + + @override + void dispose() { + _spinner?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('${widget.label}: $_count'), + FilledButton(onPressed: () => setState(() => _count++), child: const Text('plus')), + ], + ); +}