From 07463eea04487c56cad10a3f35dfbc9e41c9f8d4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:34:22 +0200 Subject: [PATCH] fix(explore): keep the page at the top when a tab switch focuses a hub Switching to the Explore tab hands focus to the first hub, and HubSection unconditionally ran Scrollable.ensureVisible, scrolling the app bar and search field off-screen in touch mode. The scroll is now gated on keyboard/D-pad input mode, mirroring FocusableWrapper's autoScroll. --- lib/widgets/hub_section.dart | 4 +++ test/widgets/hub_section_test.dart | 49 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 46eb49dfe..c10254c4f 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -252,6 +252,10 @@ class HubSectionState extends State with MountedSetStateMixin, Skele /// Scroll this hub into view in the parent scroll view void _scrollHubIntoView() { + // Programmatic focus in touch/pointer mode (e.g. a tab switch handing focus + // to the first hub) must not scroll the page; only keyboard/D-pad focus + // scrolls, mirroring FocusableWrapper.autoScroll. + if (InputModeTracker.currentMode != InputMode.keyboard) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; Scrollable.ensureVisible( diff --git a/test/widgets/hub_section_test.dart b/test/widgets/hub_section_test.dart index 5a77a609e..7ca8cb562 100644 --- a/test/widgets/hub_section_test.dart +++ b/test/widgets/hub_section_test.dart @@ -100,6 +100,55 @@ void main() { expect(longPressedItem, same(item)); }); + testWidgets('programmatic hub focus scrolls the page only in keyboard mode', (tester) async { + // Regression: switching to the Explore tab hands focus to the first hub; + // in touch mode that must not scroll the page (it hid the app bar and + // search field). Keyboard/D-pad focus keeps scrolling the hub into view. + final item = testMediaItem( + id: 'scroll_item', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Scroll Movie', + ); + final hubKey = GlobalKey(); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SingleChildScrollView( + controller: scrollController, + child: Column( + children: [ + const SizedBox(height: 900), + HubSection( + key: hubKey, + hub: _hubWith(item), + focusMemory: HubFocusMemory(), + icon: Symbols.live_tv_rounded, + ), + ], + ), + ), + ), + ), + ), + ); + + hubKey.currentState!.requestFocusFromMemory(); + await tester.pumpAndSettle(); + expect(scrollController.offset, 0, reason: 'touch-mode programmatic focus must not scroll the page'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + expect(InputModeTracker.currentMode, InputMode.keyboard, reason: 'arrow key must enter keyboard mode'); + hubKey.currentState!.requestFocusFromMemory(); + await tester.pumpAndSettle(); + expect(scrollController.offset, greaterThan(0), reason: 'keyboard-mode focus scrolls the hub into view'); + }); + testWidgets('grid poster override uses dense 2:3 TV geometry', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); final item = testMediaItem(