From 2f5f70124e9262d72969730684d37037030678c8 Mon Sep 17 00:00:00 2001 From: Muhammad usman Date: Fri, 16 Oct 2026 07:52:39 +0500 Subject: [PATCH 1/2] feat(tv): implement Back key handling to focus selected tab in TabBar --- modules/ensemble/doc/layout-widgets.md | 4 + modules/ensemble/lib/framework/device.dart | 14 + .../ensemble/lib/layout/tab/base_tab_bar.dart | 114 +++++ .../lib/layout/tab/tab_bar_controller.dart | 13 + modules/ensemble/lib/layout/tab_bar.dart | 22 + .../test/widget/tab_bar_back_focus_test.dart | 470 ++++++++++++++++++ 6 files changed, 637 insertions(+) create mode 100644 modules/ensemble/test/widget/tab_bar_back_focus_test.dart diff --git a/modules/ensemble/doc/layout-widgets.md b/modules/ensemble/doc/layout-widgets.md index b6fbeeee3..0cb44fe40 100644 --- a/modules/ensemble/doc/layout-widgets.md +++ b/modules/ensemble/doc/layout-widgets.md @@ -21,6 +21,10 @@ renders only the tab navigation. Both are backed by `BaseTabBar`, | `navigateTo(index)` | Programmatically selects a tab. `changeTabItem(index)` is kept as a legacy alias. | | `useIndexedTab` | Builds each tab body on first visit and caches it for later tab switches. | | `persistentTabBar` | Wraps tab bodies in a scroll view so the tab bar remains pinned while body content scrolls. | +| `styles.tabFocusOnBack` | TV-only, opt-in: the first Back press from tab content restores focus to the selected tab; a separate next press uses normal navigation. Defaults to `false`. | + +`tabFocusOnBack` applies to `TabBar` content; `TabBarOnly` has no body to return +from, and `ScrollableTabBar` retains its scroll-driven selection behavior. ### Example diff --git a/modules/ensemble/lib/framework/device.dart b/modules/ensemble/lib/framework/device.dart index 79486caf6..a8154f46d 100644 --- a/modules/ensemble/lib/framework/device.dart +++ b/modules/ensemble/lib/framework/device.dart @@ -91,6 +91,14 @@ class Device void openAppSettings([String? target]) { AppSettingAction.openAppSettings(target ?? 'settings'); } + + /// Test-only override of [DeviceInfoCapability.isTV] so widget tests can + /// exercise TV-only rendering without real Android device info. + /// Pass null to clear the override and fall back to platform detection. + @visibleForTesting + static void debugOverrideIsTV(bool? value) { + DeviceInfoCapability._isTVOverride = value; + } } mixin MediaQueryCapability { @@ -132,11 +140,17 @@ mixin DeviceInfoCapability { // Android TV detection cache static bool? _isTV; + /// Test-only override consulted before device detection, so a test build can + /// pretend to run on a TV without populating [androidInfo]. Declared here (not + /// in [Device]) because [isTV] reads it. See [Device.debugOverrideIsTV]. + static bool? _isTVOverride; + DevicePlatform? get platform => _platform; /// Returns true if the device is an Android TV /// Checks for TV-specific system features bool get isTV { + if (_isTVOverride != null) return _isTVOverride!; if (_isTV != null) return _isTV!; // Web is never a TV, and it's known synchronously — safe to cache. diff --git a/modules/ensemble/lib/layout/tab/base_tab_bar.dart b/modules/ensemble/lib/layout/tab/base_tab_bar.dart index bc2832547..65f57fb22 100644 --- a/modules/ensemble/lib/layout/tab/base_tab_bar.dart +++ b/modules/ensemble/lib/layout/tab/base_tab_bar.dart @@ -11,6 +11,7 @@ import 'package:ensemble/framework/widget/widget.dart'; import 'package:ensemble/layout/tab/tab_bar_controller.dart'; import 'package:ensemble/layout/tab_bar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:ensemble/framework/widget/icon.dart' as ensemble; // ============================================================================= @@ -27,6 +28,96 @@ abstract class BaseTabBarState extends EWidgetState // execute when a tab has (already) changed. Use this to run extra logic (and don't modify the tabController again void onTabChanged(int index); + // --------------------------------------------------------------------------- + // TV: return focus to the selected tab on Back + // --------------------------------------------------------------------------- + + /// The mounted TV tab buttons, keyed by visible tab index. + /// + /// Populated by [_TVTabButtonState] so [focusSelectedTab] can target the + /// selected tab directly, rather than through grid coordinates (whose + /// nearest-match fallback could land on a different control). + final Map _tvTabFocusEntries = {}; + + void _registerTVTabFocusEntry(int index, _TVTabFocusEntry entry) { + _tvTabFocusEntries[index] = entry; + } + + void _unregisterTVTabFocusEntry(int index, _TVTabFocusEntry entry) { + // Only drop the entry if it is still this one: a replacement button for the + // same index must not be removed by the outgoing one. + if (identical(_tvTabFocusEntries[index], entry)) { + _tvTabFocusEntries.remove(index); + } + } + + /// Focuses the currently selected tab button, returning whether focus moved. + /// + /// Uses the index the strip itself treats as selected, and reveals the strip + /// explicitly: the first focus gain on a tab is intentionally not revealed + /// (legacy initial-autofocus guard), so a Back press from scrolled-away + /// content would otherwise move focus somewhere the user cannot see. + /// + /// Returns false when this TabBar has no TV tab buttons (mobile/web builds), + /// which leaves the Back press to the platform. + bool focusSelectedTab() { + final entry = _tvTabFocusEntries[tabController.index]; + if (entry == null || entry.node.hasFocus || !entry.node.canRequestFocus) { + return false; + } + entry.node.requestFocus(); + entry.reveal(); + return true; + } + + // A Back key-down handled by the content scope must also consume the paired + // key-up. Android tracks the down/up pair and can redispatch an unhandled up + // to Activity.onKeyUp, which may pop the route even though focus has moved. + bool _handledTabBackKeySequence = false; + + KeyEventResult handleTabBackKeySequence(KeyEvent event) { + if (event.logicalKey != LogicalKeyboardKey.goBack || + !_handledTabBackKeySequence) { + return KeyEventResult.ignored; + } + + // A non-repeat down is a new physical press (normally preceded by the + // release); don't let a missing/cancelled up leave navigation disabled. + if (event is KeyDownEvent) { + _handledTabBackKeySequence = false; + return KeyEventResult.ignored; + } + if (event is KeyUpEvent) { + _handledTabBackKeySequence = false; + } + // Consume repeats and the paired release for the handled press. + return KeyEventResult.handled; + } + + /// Key handler for the opt-in TabBar content wrapper + /// (`styles.tabFocusOnBack: true`). + /// + /// Consumes a plain Back key-down only when focus successfully moves. That + /// handled press's repeats and key-up are then consumed by the surrounding + /// TabBar scope, so they cannot re-trigger or navigate by themselves. + /// The wrapper surrounds the tab *content* only, so once focus is on this + /// TabBar's own strip the handler is no longer in the focus path and the next + /// Back press reaches normal navigation untouched. If this TabBar is nested + /// inside another opted-in TabBar, Back walks out one tab level per press. + KeyEventResult handleTabBodyBackKey(KeyEvent event) { + if (_handledTabBackKeySequence) { + return handleTabBackKeySequence(event); + } + if (event is! KeyDownEvent || + event.logicalKey != LogicalKeyboardKey.goBack) { + return KeyEventResult.ignored; + } + if (!focusSelectedTab()) return KeyEventResult.ignored; + + _handledTabBackKeySequence = true; + return KeyEventResult.handled; + } + // common method for both scrollable and simple Tabbar bool evaluateCondition(ScopeManager scopeManager, String expression) { try { @@ -232,6 +323,8 @@ abstract class BaseTabBarState extends EWidgetState tabPadding: widget.controller.tabPadding, scrollPadding: widget.controller.tvOptions?.verticalScrollPadding, revealTabBarOnFocus: widget.controller.revealTabBarOnFocus, + registerFocusEntry: _registerTVTabFocusEntry, + unregisterFocusEntry: _unregisterTVTabFocusEntry, onTap: () { tabController.animateTo(index); onTabChanged(index); @@ -394,6 +487,16 @@ mixin TabBarAction on EWidgetState { void changeTab(int index); } +/// A mounted TV tab button, as seen by its owning TabBar: the focus node that +/// Back should target, plus the reveal that makes it visible (the button only +/// auto-reveals on a repeat focus gain, not on the first one). +class _TVTabFocusEntry { + const _TVTabFocusEntry(this.node, this.reveal); + + final FocusNode node; + final VoidCallback reveal; +} + /// TV-specific focusable tab button using row/order-based navigation. /// Each tab uses TVFocusOrder coordinates. /// If TabBar has tvRow set, tabs use that row in the main page grid. @@ -418,6 +521,8 @@ class _TVTabButton extends StatefulWidget { this.tabPadding, this.scrollPadding, this.revealTabBarOnFocus = false, + required this.registerFocusEntry, + required this.unregisterFocusEntry, }); final TabItem tabItem; @@ -441,12 +546,18 @@ class _TVTabButton extends StatefulWidget { final double? scrollPadding; final bool revealTabBarOnFocus; + /// Registers/unregisters this button with the owning TabBar, so Back inside + /// the tab content can return focus to the selected tab. + final void Function(int index, _TVTabFocusEntry entry) registerFocusEntry; + final void Function(int index, _TVTabFocusEntry entry) unregisterFocusEntry; + @override State<_TVTabButton> createState() => _TVTabButtonState(); } class _TVTabButtonState extends State<_TVTabButton> { late final FocusNode _focusNode; + late final _TVTabFocusEntry _focusEntry; bool _hasReceivedFocus = false; @override @@ -454,10 +565,13 @@ class _TVTabButtonState extends State<_TVTabButton> { super.initState(); _focusNode = FocusNode(debugLabel: 'TVTabButton_${widget.index}'); _focusNode.addListener(_onFocusChange); + _focusEntry = _TVTabFocusEntry(_focusNode, _scrollIntoView); + widget.registerFocusEntry(widget.index, _focusEntry); } @override void dispose() { + widget.unregisterFocusEntry(widget.index, _focusEntry); _focusNode.removeListener(_onFocusChange); _focusNode.dispose(); super.dispose(); diff --git a/modules/ensemble/lib/layout/tab/tab_bar_controller.dart b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart index a598efca9..6f2d71aed 100644 --- a/modules/ensemble/lib/layout/tab/tab_bar_controller.dart +++ b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart @@ -42,6 +42,17 @@ class TabBarController extends BoxController { /// TV: reveal the whole TabBar when a tab receives focus. bool revealTabBarOnFocus = false; + /// TV: when true, pressing Back while focus is inside this TabBar's content + /// moves focus back to the selected tab instead of leaving the screen. The + /// next Back press (now on the tab strip) navigates as usual. Opt-in and + /// default false, so existing behavior is unchanged. + /// + /// Applies to `TabBar` (TabBarContainer) content. `TabBarOnly` has no content + /// to return from, and `ScrollableTabBar` selects by scroll position rather + /// than by a per-tab body, so both ignore this flag. + /// Set with `styles.tabFocusOnBack`. + bool tabFocusOnBack = false; + /// Whether the TabBar body occupies the available flex height. bool get isStretched { if (flexMode == FlexMode.expanded || @@ -100,6 +111,8 @@ class TabBarController extends BoxController { 'tvRow': (value) => tvRow = Utils.optionalDouble(value), 'revealTabBarOnFocus': (value) => revealTabBarOnFocus = Utils.getBool(value, fallback: false), + 'tabFocusOnBack': (value) => + tabFocusOnBack = Utils.getBool(value, fallback: false), }); return setters; } diff --git a/modules/ensemble/lib/layout/tab_bar.dart b/modules/ensemble/lib/layout/tab_bar.dart index 3aa6b4d5f..0c4bf8ec2 100644 --- a/modules/ensemble/lib/layout/tab_bar.dart +++ b/modules/ensemble/lib/layout/tab_bar.dart @@ -332,6 +332,19 @@ class TabBarState extends BaseTabBarState { return buildSelectedTab(); }, ); + // Opt-in TV Back handling (`styles.tabFocusOnBack: true`): while + // focus is inside this TabBar's content, Back moves focus to the selected + // tab instead of leaving the screen. The wrapper surrounds the content + // only, so the tab strip and everything outside this TabBar keep their + // existing Back behavior. Non-TV builds have no TV tab buttons + // registered, so handleTabBodyBackKey leaves the press to the platform. + if (widget._controller.tabFocusOnBack) { + tabContent = Focus( + canRequestFocus: false, + onKeyEvent: (_, KeyEvent event) => handleTabBodyBackKey(event), + child: tabContent, + ); + } if (isExpanded) { tabContent = Expanded(child: tabContent); } @@ -345,6 +358,15 @@ class TabBarState extends BaseTabBarState { tabContent, ], ); + if (widget._controller.tabFocusOnBack) { + // Keep this Focus below the outer Expanded. Expanded must remain the + // direct Flex child when a TabBar sits in a Column (as on Tennis). + tabWidget = Focus( + canRequestFocus: false, + onKeyEvent: (_, event) => handleTabBackKeySequence(event), + child: tabWidget, + ); + } // if Expanded is set, stretch our column to left-over height if (isExpanded) { tabWidget = Expanded(child: tabWidget); diff --git a/modules/ensemble/test/widget/tab_bar_back_focus_test.dart b/modules/ensemble/test/widget/tab_bar_back_focus_test.dart new file mode 100644 index 000000000..54f5f4326 --- /dev/null +++ b/modules/ensemble/test/widget/tab_bar_back_focus_test.dart @@ -0,0 +1,470 @@ +import 'package:ensemble/framework/device.dart'; +import 'package:ensemble/layout/tab/tab_bar_controller.dart'; +import 'package:ensemble/layout/tab_bar.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +import 'test_utils.dart'; + +/// TV focus label used by the tap-enabled box wrapper (`_TapEnabledWrapper`), +/// i.e. the focus node of a focusable widget inside a tab body. +const String bodyFocusLabelPrefix = 'TapEnabledWrapper_'; + +/// TV focus label used by each tab strip button. +const String tabButtonFocusLabelPrefix = 'TVTabButton_'; + +/// A focusable tab body: a tap-enabled Row with TV focus coordinates, so in TV +/// mode it gets a real focus node that can hold focus inside the tab content. +Map bodyDefinition(String text) => { + 'Row': { + 'styles': { + 'tvOptions': {'row': 9, 'order': 0}, + }, + // Never executed: the body only needs to be tap-enabled so TV mode + // wraps it in a focusable node. + 'onTap': {'navigateBack': {}}, + 'children': [ + { + 'Text': {'text': text}, + }, + ], + }, + }; + +YamlList tabItems({int count = 3}) => YamlList.wrap([ + for (var i = 1; i <= count; i++) + { + 'label': 'Tab $i', + 'body': bodyDefinition('Body $i'), + }, + ]); + +/// Every goBack event that reached an ancestor above the TabBar, i.e. that the +/// TabBar did not consume and that normal navigation would therefore receive. +class BackSpy { + final List events = []; + + bool get sawKeyDown => + events.any((event) => event is KeyDownEvent); + + Widget wrap(Widget child) => Focus( + onKeyEvent: (node, event) { + if (event.logicalKey == LogicalKeyboardKey.goBack) { + events.add(event); + } + return KeyEventResult.ignored; + }, + child: child, + ); +} + +FocusNode _nodeWithLabel(WidgetTester tester, String prefix) { + return tester.binding.focusManager.rootScope.descendants.firstWhere( + (node) => node.debugLabel?.startsWith(prefix) ?? false, + orElse: () => throw StateError('no focus node labelled $prefix*')); +} + +String? _primaryLabel(WidgetTester tester) => + tester.binding.focusManager.primaryFocus?.debugLabel; + +Future _focusBodyContent(WidgetTester tester) async { + final node = _nodeWithLabel(tester, bodyFocusLabelPrefix); + node.requestFocus(); + await tester.pump(); + return node; +} + +Future _pressBackDown(WidgetTester tester) => tester.sendKeyDownEvent( + LogicalKeyboardKey.goBack, + // Back has no counterpart in PhysicalKeyboardKey, so the simulator needs + // an explicit physical key to build the event. + physicalKey: PhysicalKeyboardKey.escape, + ); + +Future _repeatBack(WidgetTester tester) => tester.sendKeyRepeatEvent( + LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.escape, + ); + +Future _releaseBack(WidgetTester tester) => tester.sendKeyUpEvent( + LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.escape, + ); + +void main() { + setUp(() { + // TabBar only builds its D-pad focusable strip on TV. + Device.debugOverrideIsTV(true); + }); + + tearDown(() { + Device.debugOverrideIsTV(null); + }); + + testWidgets('Back in tab content focuses the selected tab, not the page', + (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + await _focusBodyContent(tester); + expect(_primaryLabel(tester), startsWith(bodyFocusLabelPrefix)); + + await _pressBackDown(tester); + + // Focus returned to the (selected) tab strip and the press was consumed. + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + expect(spy.events, isEmpty); + + // A held Back button may generate repeat downs after focus has moved. + // They belong to this same physical press and must not escape to navigation. + await _repeatBack(tester); + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + expect(spy.events, isEmpty); + + await _releaseBack(tester); + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + expect(spy.events, isEmpty); + + // Back did not select, reselect, or rebuild the tab. + expect(tabBar.controller.selectedIndex, 0); + expect(find.text('Body 1'), findsOneWidget); + + // The next Back press is on the strip, so the TabBar must not consume it: + // the page keeps whatever Back behavior it already had. + await _pressBackDown(tester); + expect(spy.sawKeyDown, isTrue); + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + expect(tabBar.controller.selectedIndex, 0); + await _releaseBack(tester); + expect(spy.events.whereType(), hasLength(1)); + }); + + testWidgets('Back in tab content targets the selected tab, not the focused one', + (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + // Autofocus lands on tab 0 while tab 2 is the selected tab. + ..setProperty('autofocus', true) + ..setProperty('selectedIndex', 2) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + + await _focusBodyContent(tester); + await _pressBackDown(tester); + + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}2'); + expect(tabBar.controller.selectedIndex, 2); + expect(find.text('Body 3'), findsOneWidget); + }); + + testWidgets('Back in tab content targets a tab selected at runtime', + (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(TabBarContainer)); + state.changeTab(2); + await tester.pumpAndSettle(); + expect(find.text('Body 3'), findsOneWidget); + + await _focusBodyContent(tester); + await _pressBackDown(tester); + + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}2'); + expect(tabBar.controller.selectedIndex, 2); + expect(spy.events, isEmpty); + }); + + testWidgets('Back ignores repeats, key-up and other keys', (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(TabBarContainer)); + await _focusBodyContent(tester); + + expect(state.handleTabBodyBackKey(const KeyRepeatEvent( + logicalKey: LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.escape, + timeStamp: Duration.zero, + )), KeyEventResult.ignored); + expect(_primaryLabel(tester), startsWith(bodyFocusLabelPrefix)); + + expect(state.handleTabBodyBackKey(const KeyUpEvent( + logicalKey: LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.escape, + timeStamp: Duration.zero, + )), KeyEventResult.ignored); + expect(_primaryLabel(tester), startsWith(bodyFocusLabelPrefix)); + + expect(state.handleTabBodyBackKey(const KeyDownEvent( + logicalKey: LogicalKeyboardKey.escape, + physicalKey: PhysicalKeyboardKey.escape, + timeStamp: Duration.zero, + )), KeyEventResult.ignored); + expect(_primaryLabel(tester), startsWith(bodyFocusLabelPrefix)); + + expect(state.handleTabBodyBackKey(const KeyUpEvent( + logicalKey: LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.escape, + timeStamp: Duration.zero, + )), KeyEventResult.ignored); + + // Only the plain Back key-down moves focus. + expect(state.handleTabBodyBackKey(const KeyDownEvent( + logicalKey: LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.escape, + timeStamp: Duration.zero, + )), KeyEventResult.handled); + // Focus changes are applied by the focus manager on the next frame. + await tester.pump(); + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + }); + + testWidgets('Default behavior leaves Back inside tab content untouched', + (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer()..setProperty('items', tabItems()); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + expect(tabBar.controller.tabFocusOnBack, isFalse); + + await _focusBodyContent(tester); + await _pressBackDown(tester); + + expect(spy.sawKeyDown, isTrue); + expect(_primaryLabel(tester), startsWith(bodyFocusLabelPrefix)); + }); + + testWidgets('Hidden tabs are never targeted', (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty( + 'items', + YamlList.wrap([ + { + 'label': 'Always', + 'body': bodyDefinition('Body always'), + }, + { + 'label': 'Hidden', + 'visible': false, + 'body': bodyDefinition('Body hidden'), + }, + { + 'label': 'Last', + 'body': bodyDefinition('Body last'), + }, + ])) + ..setProperty('selectedIndex', 1) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + // Visible index 1 is 'Last'; the hidden tab must not shift focus. + expect(find.text('Body last'), findsOneWidget); + expect(find.text('Hidden'), findsNothing); + + await _focusBodyContent(tester); + await _pressBackDown(tester); + + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}1'); + expect(find.text('Hidden'), findsNothing); + }); + + testWidgets('Back-to-selected-tab is inert when the strip is not TV focusable', + (tester) async { + // Non-TV: no D-pad strip is built, so Back must reach the platform as + // before even when the option is enabled. + Device.debugOverrideIsTV(false); + + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(TabBarContainer)); + expect(state.focusSelectedTab(), isFalse); + expect(state.handleTabBodyBackKey(const KeyDownEvent( + logicalKey: LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.escape, + timeStamp: Duration.zero, + )), KeyEventResult.ignored); + expect(find.text('Body 1'), findsOneWidget); + }); + + testWidgets('Back reveals the selected tab when the strip has scrolled away', + (tester) async { + final spy = BackSpy(); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + spy.wrap( + SizedBox( + height: 400, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + children: [ + tabBar, + const SizedBox(height: 1200), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + // Focus starts inside the content and the user scrolls the page down, so + // the strip is no longer on screen. + await _focusBodyContent(tester); + await tester.pumpAndSettle(); + scrollController.jumpTo(500); + await tester.pumpAndSettle(); + expect(tester.getRect(find.text('Tab 1')).top, lessThan(0)); + + await _pressBackDown(tester); + await tester.pumpAndSettle(); + + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + // The strip is revealed, so the focus ring is actually visible. + expect(tester.getRect(find.text('Tab 1')).top, greaterThanOrEqualTo(0)); + }); + + testWidgets('Back works with indexed tab content', (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('useIndexedTab', true) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + await _focusBodyContent(tester); + await _pressBackDown(tester); + + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + expect(spy.events, isEmpty); + }); + + testWidgets('expanded Back-enabled TabBar keeps its Flex layout', + (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('expanded', true) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + spy.wrap(Column(children: [tabBar])), + )); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Tab 1'), findsOneWidget); + expect(find.text('Body 1'), findsOneWidget); + + await _focusBodyContent(tester); + await _pressBackDown(tester); + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + expect(spy.events, isEmpty); + await _releaseBack(tester); + expect(spy.events, isEmpty); + + // A distinct second physical press remains available for page navigation. + await _pressBackDown(tester); + expect(spy.sawKeyDown, isTrue); + await _releaseBack(tester); + }); + + testWidgets('Registry drops tab nodes that disappear at runtime', + (tester) async { + final spy = BackSpy(); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('selectedIndex', 2) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget( + TestUtils.wrapTestWidgetWithScope(spy.wrap(tabBar))); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(TabBarContainer)); + + // Hide the last tab while the page is live. + tabBar.controller.originalItems[2].isVisible = BoolVisible(false); + state.handleConditionalTabs(); + await tester.pumpAndSettle(); + + expect(find.text('Tab 3'), findsNothing); + // The out-of-range selection falls back to the first tab. + expect(tabBar.controller.selectedIndex, 0); + + await _focusBodyContent(tester); + await _pressBackDown(tester); + + // Focuses a live tab rather than a disposed node from the removed tab. + expect(tester.takeException(), isNull); + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + }); + + test('Back-to-selected-tab is opt-in and off by default', () { + final controller = TabBarController(); + + expect(controller.tabFocusOnBack, isFalse); + + controller.getBaseSetters()['tabFocusOnBack']!(true); + expect(controller.tabFocusOnBack, isTrue); + }); + + test('Back-to-selected-tab resolves from styles like other TV options', () { + final tabBar = TabBarContainer(); + + // `styles:` keys are applied through the widget's settable properties, so + // the option must be listed there to be usable from YAML. + expect(Invokable.getSettableProperties(tabBar), + contains('tabFocusOnBack')); + }); +} From 1bc91680020f48447eb49ad03a73f25b8d146490 Mon Sep 17 00:00:00 2001 From: Muhammad usman Date: Fri, 16 Oct 2026 15:31:53 +0500 Subject: [PATCH 2/2] feat(tv): enhance Back key handling for TabBar navigation and add tests --- .../lib/framework/tv/tv_focus_widget.dart | 3 +- .../ensemble/lib/layout/tab/base_tab_bar.dart | 5 ++- modules/ensemble/lib/layout/tab_bar.dart | 2 +- .../test/widget/tab_bar_back_focus_test.dart | 43 +++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/modules/ensemble/lib/framework/tv/tv_focus_widget.dart b/modules/ensemble/lib/framework/tv/tv_focus_widget.dart index 0c7a244c8..6142e6870 100644 --- a/modules/ensemble/lib/framework/tv/tv_focus_widget.dart +++ b/modules/ensemble/lib/framework/tv/tv_focus_widget.dart @@ -260,7 +260,8 @@ class TVFocusWidget extends StatelessWidget { } // Check if trying to exit at top boundary (UP and at first row in grid) - // Let the event propagate to native focus handling (e.g., sport tab) + // Let the event propagate to native focus handling (e.g., a native tab or + // screen hosting this Ensemble view) // so users can navigate back to native content from Ensemble content if (yOffset == -1 && y == 0) { if (topEdgeHandler != null) { diff --git a/modules/ensemble/lib/layout/tab/base_tab_bar.dart b/modules/ensemble/lib/layout/tab/base_tab_bar.dart index 65f57fb22..60f925f63 100644 --- a/modules/ensemble/lib/layout/tab/base_tab_bar.dart +++ b/modules/ensemble/lib/layout/tab/base_tab_bar.dart @@ -106,7 +106,10 @@ abstract class BaseTabBarState extends EWidgetState /// inside another opted-in TabBar, Back walks out one tab level per press. KeyEventResult handleTabBodyBackKey(KeyEvent event) { if (_handledTabBackKeySequence) { - return handleTabBackKeySequence(event); + final result = handleTabBackKeySequence(event); + // A fresh key-down means the previous press's key-up never arrived; the + // flag is now cleared, so handle this press normally. + if (event is! KeyDownEvent) return result; } if (event is! KeyDownEvent || event.logicalKey != LogicalKeyboardKey.goBack) { diff --git a/modules/ensemble/lib/layout/tab_bar.dart b/modules/ensemble/lib/layout/tab_bar.dart index 0c4bf8ec2..6e1a6acbe 100644 --- a/modules/ensemble/lib/layout/tab_bar.dart +++ b/modules/ensemble/lib/layout/tab_bar.dart @@ -360,7 +360,7 @@ class TabBarState extends BaseTabBarState { ); if (widget._controller.tabFocusOnBack) { // Keep this Focus below the outer Expanded. Expanded must remain the - // direct Flex child when a TabBar sits in a Column (as on Tennis). + // direct Flex child when the TabBar sits in a Column with `expanded: true`. tabWidget = Focus( canRequestFocus: false, onKeyEvent: (_, event) => handleTabBackKeySequence(event), diff --git a/modules/ensemble/test/widget/tab_bar_back_focus_test.dart b/modules/ensemble/test/widget/tab_bar_back_focus_test.dart index 54f5f4326..ce7a9c770 100644 --- a/modules/ensemble/test/widget/tab_bar_back_focus_test.dart +++ b/modules/ensemble/test/widget/tab_bar_back_focus_test.dart @@ -148,6 +148,49 @@ void main() { expect(spy.events.whereType(), hasLength(1)); }); + testWidgets('missed Back key-up does not prevent the next focus return', + (tester) async { + final spy = BackSpy(); + final outsideFocus = FocusNode(debugLabel: 'OutsideTabBar'); + addTearDown(outsideFocus.dispose); + final tabBar = TabBarContainer() + ..setProperty('items', tabItems()) + ..setProperty('tabFocusOnBack', true); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + spy.wrap( + Column( + children: [ + tabBar, + Focus(focusNode: outsideFocus, child: const SizedBox(height: 20)), + ], + ), + ), + )); + await tester.pumpAndSettle(); + + await _focusBodyContent(tester); + await _pressBackDown(tester); + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + + // Move outside the TabBar before release, so its sequence handler misses + // the key-up and retains a stale handled flag. + outsideFocus.requestFocus(); + await tester.pump(); + expect(outsideFocus.hasFocus, isTrue); + await _releaseBack(tester); + + final deliveredDowns = spy.events.whereType().length; + await _focusBodyContent(tester); + await _pressBackDown(tester); + + // The fresh press clears the stale sequence and still receives ordinary + // body handling instead of escaping to page navigation. + expect(_primaryLabel(tester), '${tabButtonFocusLabelPrefix}0'); + expect(spy.events.whereType(), hasLength(deliveredDowns)); + await _releaseBack(tester); + }); + testWidgets('Back in tab content targets the selected tab, not the focused one', (tester) async { final spy = BackSpy();