diff --git a/lib/app.dart b/lib/app.dart index 582215a1..6b7421ef 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -192,16 +192,18 @@ class SpotiFLACApp extends ConsumerWidget { // dialogs stay centered on large/foldable devices. builder: (context, child) { final mediaQuery = MediaQuery.of(context); + final appContent = _OrientationFade( + child: child ?? const SizedBox.shrink(), + ); return MediaQuery( data: mediaQuery.copyWith(displayFeatures: const []), - // Global Hero kill switch: heroes below a disabled HeroMode - // never register, so no flights run on any navigator. - child: HeroMode( - enabled: heroAnimationsEnabled, - child: _OrientationFade( - child: child ?? const SizedBox.shrink(), - ), - ), + // HeroMode only affects heroes below a *route* subtree. At this + // level it sits above the router's Navigator, so the controller + // never encounters it while collecting heroes. Removing the + // inherited controller disables flights on the root Navigator. + child: heroAnimationsEnabled + ? appContent + : HeroControllerScope.none(child: appContent), ); }, routerConfig: router, diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 869b9c77..1964c1ed 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -506,6 +506,9 @@ class _MainShellState extends ConsumerState final showStore = ref.watch( settingsProvider.select((s) => s.showExtensionStore), ); + final heroAnimationsEnabled = ref.watch( + settingsProvider.select((s) => s.heroAnimationsEnabled), + ); ShellNavigationService.syncState( currentTabIndex: _currentIndex, showRepoTab: showStore, @@ -519,12 +522,14 @@ class _MainShellState extends ConsumerState key: const ValueKey('tab-home'), navigatorKey: _homeTabNavigatorKey, observers: [_homePreviewStopObserver], + heroAnimationsEnabled: heroAnimationsEnabled, child: const HomeTab(), ), _TabNavigator( key: const ValueKey('tab-library'), navigatorKey: _libraryTabNavigatorKey, observers: [_libraryPreviewStopObserver], + heroAnimationsEnabled: heroAnimationsEnabled, child: _LibraryTabRoot(parentPageController: _pageController), ), if (showStore) @@ -532,6 +537,7 @@ class _MainShellState extends ConsumerState key: const ValueKey('tab-repo'), navigatorKey: _repoTabNavigatorKey, observers: [_repoPreviewStopObserver], + heroAnimationsEnabled: heroAnimationsEnabled, child: const RepoTab(), ), const SettingsTab(), @@ -750,12 +756,14 @@ class _TabNavigator extends StatefulWidget { final GlobalKey navigatorKey; final Widget child; final List observers; + final bool heroAnimationsEnabled; const _TabNavigator({ super.key, required this.navigatorKey, required this.child, this.observers = const [], + required this.heroAnimationsEnabled, }); @override @@ -778,7 +786,10 @@ class _TabNavigatorState extends State<_TabNavigator> { Widget build(BuildContext context) { return Navigator( key: widget.navigatorKey, - observers: [_heroController, ...widget.observers], + observers: [ + if (widget.heroAnimationsEnabled) _heroController, + ...widget.observers, + ], onGenerateInitialRoutes: (_, _) => [ MaterialPageRoute(builder: (_) => widget.child), ], diff --git a/test/hero_animation_controller_test.dart b/test/hero_animation_controller_test.dart new file mode 100644 index 00000000..40baa747 --- /dev/null +++ b/test/hero_animation_controller_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'HeroControllerScope.none reaches the MaterialApp root Navigator', + (tester) async { + final navigatorKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigatorKey, + builder: (context, child) => + HeroControllerScope.none(child: child ?? const SizedBox.shrink()), + home: const SizedBox.shrink(), + ), + ); + + final navigatorContext = navigatorKey.currentContext!; + final scope = navigatorContext + .getInheritedWidgetOfExactType(); + expect(scope, isNotNull); + expect(scope!.controller, isNull); + }, + ); + + testWidgets('removing an explicit HeroController detaches it', ( + tester, + ) async { + final navigatorKey = GlobalKey(); + final controller = MaterialApp.createMaterialHeroController(); + var enabled = true; + late StateSetter setHostState; + + await tester.pumpWidget( + MaterialApp( + home: HeroControllerScope.none( + child: StatefulBuilder( + builder: (context, setState) { + setHostState = setState; + return Navigator( + key: navigatorKey, + observers: [if (enabled) controller], + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => const SizedBox.shrink(), + ), + ); + }, + ), + ), + ), + ); + + expect(controller.navigator, same(navigatorKey.currentState)); + + setHostState(() => enabled = false); + await tester.pump(); + + expect(controller.navigator, isNull); + controller.dispose(); + }); +}