diff --git a/modules/bracket/README.md b/modules/bracket/README.md index b5d4c9102..b79aa88f8 100644 --- a/modules/bracket/README.md +++ b/modules/bracket/README.md @@ -11,6 +11,7 @@ This is an optional Ensemble widget module. The package exports `lib/src/bracket - Implements the Ensemble widget type `Bracket`. - Supports round and match templates through `RoundTemplate` and `MatchTemplate`. - Provides bracket rendering widgets and a custom painter for bracket connectors. +- Full TV/Android TV D-pad navigation support with customizable focus styling. ## Installation / Setup @@ -28,22 +29,180 @@ The verified public import is: import 'package:ensemble_bracket/ensemble_bracket.dart'; ``` -A complete Ensemble YAML example was not found in this package, so no YAML data shape is documented here. +### Basic YAML Example (Mobile/Web) + +```yaml +Bracket: + id: bracket + styles: + scale: 0.75 # Layout scale (0.1 - 1.0) + borderColor: transparent + borderWidth: 2 + lineStyles: + color: 0xff404040 + width: 2 + tabStyles: + backgroundColor: 0xFF232323 + selectedBackgroundColor: 0xFF00C300 + textStyle: + color: 0xFFB3B3B3 + selectedTextStyle: + color: black + borderRadius: 8 + + items: + data: ${bracketData} + name: round + title: ${round.title} + item-template: + data: ${round.matches} + name: match + height: 100 + template: + MatchCard: + inputs: + match: ${match} +``` + +### TV YAML Example (Android TV / D-pad) + +```yaml +Bracket: + id: bracket + styles: + scale: 0.4 # Smaller scale for TV (more columns visible) + borderColor: transparent + borderWidth: 2 + tvOptions: + row: 1 # TV: Tab row, matches start at row+1 + lineStyles: + color: 0xff404040 + width: 2 + tabStyles: + backgroundColor: 0xFF232323 + selectedBackgroundColor: 0xFF00C300 + textStyle: + color: 0xFFB3B3B3 + selectedTextStyle: + color: black + borderRadius: 8 + focusBorderRadius: 12 # TV: Focus indicator radius + + items: + data: ${bracketData} + name: round + title: ${round.title} + item-template: + data: ${round.matches} + name: match + height: 100 + template: + MatchCard: + inputs: + match: ${match} +``` ## Configuration -No additional configuration was found in this package. +### Bracket Properties + +| Property | Type | TV | Description | +| ------------- | ------ | :-: | ------------------------------- | +| `borderColor` | Color | | Border color around match cards | +| `borderWidth` | Number | | Border width around match cards | + +### Scale (`scale`) + +| Property | Type | Description | +| -------- | ------ | -------------------------------------------------------------------------------------------------------------------- | +| `scale` | Number | Layout scale (0.1 - 1.0, default: 0.75). Controls viewportFraction, card width, and connector length proportionally. | + +### Line Styles (`lineStyles`) + +| Property | Type | Description | +| -------- | ------ | -------------------------------- | +| `color` | Color | Color of bracket connector lines | +| `width` | Number | Width of bracket connector lines | + +> **Note**: Vertical connector lines automatically scale based on card spacing when navigating between rounds, ensuring lines always connect properly at any scale. + +### Tab Styles (`tabStyles`) + +| Property | Type | TV | Description | +| -------------------------- | ---------- | :-: | --------------------------------------------- | +| `backgroundColor` | Color | | Background color of unselected tabs | +| `selectedBackgroundColor` | Color | | Background color of selected tab | +| `textStyle` | TextStyle | | Text style for unselected tabs | +| `selectedTextStyle` | TextStyle | | Text style for selected tab | +| `borderRadius` | Number | | Border radius of tabs | +| `borderColor` | Color | | Border color of tabs | +| `borderWidth` | Number | | Border width of tabs | +| `padding` | EdgeInsets | | Padding inside tabs | +| `gap` | Number | | Gap between tabs (default: 12) | +| `focusBorderColor` | Color | ✅ | Focus border color when navigating with D-pad | +| `focusBorderWidth` | Number | ✅ | Focus border width (default: 2.0) | +| `focusBorderRadius` | Number | ✅ | Focus border radius (default: 8.0) | +| `focusBackgroundColor` | Color | ✅ | Background color when focused | +| `focusTextStyle` | TextStyle | ✅ | Text style when focused | +| `focusAnimationDurationMs` | Number | ✅ | Focus animation duration in milliseconds | + +### TV Options (`tvOptions`) + +| Property | Type | TV | Description | +| -------- | ------ | :-: | ---------------------------------------------------------------------- | +| `row` | Number | ✅ | TV navigation row offset. Tabs are at this row, matches start at row+1 | + +--- + +## TV D-pad Navigation + +> **Note**: This section only applies to Android TV / TV devices with D-pad remote control. + +### Focus Styling Priority Chain + +The bracket widget follows the same focus styling priority as other Ensemble widgets: + +1. **tabStyles focus properties** (e.g., `focusBorderColor`, `focusBorderWidth`) +2. **Theme** (`EnsembleThemeExtension.tvFocusTheme`) +3. **Provider** (`TVFocusProviderScope`) +4. **tabStyles regular properties** (e.g., `borderColor`, `borderWidth`) +5. **Default values** (focusBorderWidth: 2.0, focusBorderRadius: 8.0) + +### Navigation Behavior + +The bracket widget provides full TV D-pad navigation: + +- **LEFT/RIGHT** arrows navigate between rounds (columns) +- **UP/DOWN** arrows navigate between matches within a round +- Focus automatically transfers to the corresponding row when changing rounds +- Row clamping ensures focus stays within available matches (e.g., 8 matches in Round of 16 → 4 matches in Quarter Finals) + +### Match Card tvOptions + +Match cards within the bracket should include `tvOptions` for focus styling: + +```yaml +MatchCard: + body: + Column: + styles: + tvOptions: + row: 0 # Required for box_wrapper focus styling (actual row is set by bracket) + backgroundColor: 0xff303030 # Background when focused +``` + +Note: The `row` value is overridden by the bracket for navigation ordering, but must be present for box_wrapper to apply focus styling. ## Platform Support -| Platform | Supported | Notes | -| -------- | --------: | ----- | -| Android | Unknown | No Android project is included in this package; host app setup is required. | -| iOS | Unknown | No iOS project is included in this package; host app setup is required. | -| Web | Unknown | No web implementation was found in this package. | -| macOS | Unknown | No macOS project is included in this package. | -| Windows | Unknown | No Windows project is included in this package. | -| Linux | Unknown | No Linux project is included in this package. | +| Platform | Supported | Notes | +| -------- | --------: | ------------------------------------ | +| Android | ✅ | Full support including Android TV | +| iOS | ✅ | Full support | +| Web | ✅ | Full support | +| macOS | ✅ | Full support | +| Windows | ✅ | Full support | +| Linux | - | Not listed in pubspec.yaml platforms | ## Permissions @@ -51,12 +210,13 @@ No runtime permissions were found in this package. ## API Reference -| API | Type | Description | -| --- | ---- | ----------- | -| `EnsembleBracketImpl` | Widget | Ensemble widget implementation for the `Bracket` type. | -| `BracketController` | Controller | Holds bracket properties and template configuration. | -| `RoundTemplate` | Template | Template model for rounds. | -| `MatchTemplate` | Template | Template model for matches. | +| API | Type | Description | +| --------------------- | ---------- | --------------------------------------------------------- | +| `EnsembleBracketImpl` | Widget | Ensemble widget implementation for the `Bracket` type. | +| `BracketController` | Controller | Holds bracket properties and template configuration. | +| `RoundTemplate` | Template | Template model for rounds. | +| `MatchTemplate` | Template | Template model for matches. | +| `RoundData` | Data | Resolved round data with title, matches, and local scope. | ## Development diff --git a/modules/bracket/lib/src/bracket.dart b/modules/bracket/lib/src/bracket.dart index 2f213b050..03ea237ea 100644 --- a/modules/bracket/lib/src/bracket.dart +++ b/modules/bracket/lib/src/bracket.dart @@ -1,15 +1,21 @@ // ignore_for_file: avoid_print +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/ensemble_widget.dart'; import 'package:ensemble/framework/model.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/framework/stub/ensemble_bracket.dart'; +import 'package:ensemble/framework/theme/theme_loader.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; +import 'package:ensemble/framework/tv/tv_focus_widget.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/layout/templated.dart'; import 'package:ensemble/model/item_template.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; /// Implementation of the tournament bracket widget for Ensemble. class EnsembleBracketImpl extends EnsembleWidget @@ -29,6 +35,7 @@ class EnsembleBracketImpl extends EnsembleWidget class RoundTemplate extends ItemTemplate { /// The title of the round. final String? title; + /// The matches within the round. final MatchTemplate matches; @@ -55,8 +62,10 @@ class MatchTemplate extends ItemTemplate { class RoundData { /// The title of the round. final String title; + /// The match template configuration. final MatchTemplate matches; + /// The local scope manager for evaluated variables in the round. final ScopeManager localScope; @@ -88,6 +97,30 @@ class BracketController extends EnsembleBoxController { TextStyle? tabTextStyle; TextStyle? tabSelectedStyle; EBorderRadius? tabBorderRadius; + Color? tabBorderColor; + double? tabBorderWidth; + + // Tab focus styling (TV D-pad) + Color? tabFocusColor; + double? tabFocusBorderWidth; + EBorderRadius? tabFocusBorderRadius; + int? tabFocusAnimationDurationMs; + Color? tabFocusBackgroundColor; + TextStyle? tabFocusTextStyle; + + // TV navigation row offset - tabs will be at this row, matches at row+1, row+2, etc. + int tvRowOffset = 0; + + // Layout scale (0.1 - 1.0). Baseline 0.75 = current defaults. + // Controls viewportFraction, matchCardWidthFraction, and connectorLength proportionally. + double? _scale; + + // Computed layout values based on scale + // Baseline: scale=0.75 → viewportFraction=0.75, cardWidth=0.6, connector=25 + double get viewportFraction => _scale ?? 0.75; + double get matchCardWidthFraction => + _scale != null ? 0.6 * (_scale! / 0.75) : 0.6; + double get connectorLength => _scale != null ? 25.0 * (_scale! / 0.75) : 25.0; @override List passthroughSetters() => ['items']; @@ -105,6 +138,17 @@ class BracketController extends EnsembleBoxController { @override Map setters() => Map.from(super.setters()) ..addAll({ + 'scale': (value) { + final parsed = Utils.optionalDouble(value); + if (parsed != null) { + _scale = parsed.clamp(0.1, 1.0); + } + }, + 'tvOptions': (data) { + if (data is Map) { + tvRowOffset = Utils.getInt(data['row'], fallback: 0); + } + }, 'lineStyles': (data) { lineColor = Utils.getColor(data['color']); lineWidth = Utils.optionalDouble(data['width']); @@ -116,8 +160,21 @@ class BracketController extends EnsembleBoxController { tabTextStyle = Utils.getTextStyle(data['textStyle']); tabSelectedStyle = Utils.getTextStyle(data['selectedTextStyle']); tabBorderRadius = Utils.getBorderRadius(data['borderRadius']); + tabBorderColor = Utils.getColor(data['borderColor']); + tabBorderWidth = Utils.optionalDouble(data['borderWidth']); tabPadding = Utils.optionalInsets(data['padding']); tabGap = Utils.getDouble(data['gap'], fallback: 12.0); + // Focus styling (TV D-pad) + // Priority: focusBorderColor > Theme > Provider > borderColor > app primary + // Priority: focusBorderWidth > Theme > Provider > borderWidth > default (2.0) + // Priority: focusBorderRadius > Theme > Provider > borderRadius > default (8.0) + tabFocusColor = Utils.getColor(data['focusBorderColor']); + tabFocusBorderWidth = Utils.optionalDouble(data['focusBorderWidth']); + tabFocusBorderRadius = Utils.getBorderRadius(data['focusBorderRadius']); + tabFocusAnimationDurationMs = + Utils.optionalInt(data['focusAnimationDurationMs']); + tabFocusBackgroundColor = Utils.getColor(data['focusBackgroundColor']); + tabFocusTextStyle = Utils.getTextStyle(data['focusTextStyle']); }, 'items': (data) { if (!_isValidData(data)) return; @@ -128,7 +185,7 @@ class BracketController extends EnsembleBoxController { title: Utils.optionalString(data['title']), matches: MatchTemplate( Utils.optionalString(data['item-template']['data']), - Utils.optionalString(data['item-template']['name']) ?? 'march', + Utils.optionalString(data['item-template']['name']) ?? 'match', data['item-template']['template'], Utils.getDouble( data['item-template']['height'], @@ -158,6 +215,9 @@ class BracketController extends EnsembleBoxController { class BracketState extends EnsembleWidgetState with TemplatedWidgetState { List roundData = []; + // Keep FocusTraversalGroup stable across rebuilds - must be at this level + // because BracketsView is recreated when roundData changes + final _focusTraversalGroupKey = GlobalKey(); @override void didChangeDependencies() { @@ -171,9 +231,12 @@ class BracketState extends EnsembleWidgetState RoundTemplate? itemTemplate = widget.controller.roundTemplate; ScopeManager? myScope = DataScopeWidget.getScope(context); if (myScope != null && itemTemplate != null) { - for (dynamic dataItem in dataList) { + for (int i = 0; i < dataList.length; i++) { + dynamic dataItem = dataList[i]; ScopeManager dataScope = myScope.createChildScope(); dataScope.dataContext.addDataContextById(itemTemplate.name, dataItem); + // Add roundIndex to scope for TV navigation (tvOptions.order: ${roundIndex}) + dataScope.dataContext.addDataContextById('roundIndex', i); roundDataConfig.add( RoundData( @@ -207,6 +270,8 @@ class BracketState extends EnsembleWidgetState return BracketsView( controller: widget.controller, data: roundData, + focusTraversalGroupKey: _focusTraversalGroupKey, + tvRowOffset: widget.controller.tvRowOffset, ); } } @@ -214,11 +279,15 @@ class BracketState extends EnsembleWidgetState class BracketsView extends StatefulWidget { final List data; final BracketController controller; + final GlobalKey? focusTraversalGroupKey; + final int tvRowOffset; const BracketsView({ super.key, required this.data, required this.controller, + this.focusTraversalGroupKey, + this.tvRowOffset = 0, }); @override @@ -228,20 +297,46 @@ class BracketsView extends StatefulWidget { class _BracketsViewState extends State { late PageController _pageController; int _currentPageIndex = 0; + // Tracks the target page for animation - controls column expansion + int _prevColumnIndex = 0; List _tabKeys = []; + // Provider to tell children that bracket handles horizontal scrolling + final _bracketTVFocusProvider = _BracketTVFocusProvider(); @override void initState() { super.initState(); - _pageController = PageController(viewportFraction: 0.75); + _pageController = PageController( + viewportFraction: widget.controller.viewportFraction, + ); _pageController.addListener(_updatePageIndex); } + @override + void didUpdateWidget(covariant BracketsView oldWidget) { + super.didUpdateWidget(oldWidget); + // Recreate PageController if viewportFraction changed + if (oldWidget.controller.viewportFraction != + widget.controller.viewportFraction) { + final currentPage = _pageController.page?.round() ?? 0; + _pageController.removeListener(_updatePageIndex); + _pageController.dispose(); + _pageController = PageController( + viewportFraction: widget.controller.viewportFraction, + initialPage: currentPage, + ); + _pageController.addListener(_updatePageIndex); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); - _tabKeys = - List.generate(widget.data.length, (index) => GlobalKey()); + // Only regenerate if data length changed + if (_tabKeys.length != widget.data.length) { + _tabKeys = + List.generate(widget.data.length, (index) => GlobalKey()); + } } void _updatePageIndex() { @@ -256,15 +351,22 @@ class _BracketsViewState extends State { void _scrollToSelectedTab(int index) { if (index < _tabKeys.length) { - Scrollable.ensureVisible( - _tabKeys[index].currentContext!, - duration: const Duration(milliseconds: 300), - alignment: 0.5, - ); + final context = _tabKeys[index].currentContext; + if (context != null) { + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 300), + alignment: 0.5, + ); + } } } void _animateToPage(int index) { + // Update _prevColumnIndex BEFORE animation to trigger column expansion + setState(() { + _prevColumnIndex = index; + }); _pageController.animateToPage( index, duration: const Duration(milliseconds: 300), @@ -281,7 +383,7 @@ class _BracketsViewState extends State { @override Widget build(BuildContext context) { - return Column( + Widget content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_tabKeys.isNotEmpty) @@ -293,6 +395,17 @@ class _BracketsViewState extends State { children: List.generate(widget.data.length, (index) { bool isSelected = index == _currentPageIndex; String? title = widget.data.elementAt(index).title; + + // Wrap with TVFocusWidget for D-pad navigation on TV + if (Device().isTV) { + return _buildTVTabButton( + context, + index: index, + title: title, + isSelected: isSelected, + ); + } + return Container( padding: EdgeInsets.only(left: widget.controller.tabGap), child: ElevatedButton( @@ -325,14 +438,198 @@ class _BracketsViewState extends State { ), ), Expanded( - child: BracketsPage( - controller: widget.controller, - pageController: _pageController, - data: widget.data, - ), + // Wrap with TVFocusProviderScope to tell child widgets that + // the bracket handles horizontal scrolling via PageView. + // This prevents box_wrapper from calling Scrollable.ensureVisible() + // which would cause horizontal jerk when navigating UP/DOWN. + child: Device().isTV + ? TVFocusProviderScope( + provider: _bracketTVFocusProvider, + child: BracketsPage( + controller: widget.controller, + pageController: _pageController, + data: widget.data, + tvRowOffset: widget.tvRowOffset, + prevColumnIndex: _prevColumnIndex, + onPrevColumnIndexChanged: (index) { + setState(() { + _prevColumnIndex = index; + }); + }, + ), + ) + : BracketsPage( + controller: widget.controller, + pageController: _pageController, + data: widget.data, + tvRowOffset: widget.tvRowOffset, + prevColumnIndex: _prevColumnIndex, + onPrevColumnIndexChanged: (index) { + setState(() { + _prevColumnIndex = index; + }); + }, + ), ), ], ); + + // NOTE: We no longer wrap with FocusTraversalGroup here because: + // 1. The outer View already has a FocusTraversalGroup with TVFocusOrderTraversalPolicy + // 2. Nested FocusTraversalGroups isolate focus, preventing navigation from header (BackArrow) to bracket + // The outer View's FocusTraversalGroup handles all TV navigation using row/order from tvOptions. + + return content; + } + + /// Build a TV-focusable tab button with focus styling + Widget _buildTVTabButton( + BuildContext context, { + required int index, + required String title, + required bool isSelected, + }) { + return _TVTabButton( + tabKey: _tabKeys[index], + index: index, + title: title, + isSelected: isSelected, + controller: widget.controller, + tvRowOffset: widget.tvRowOffset, + onPressed: () { + _animateToPage(index); + _scrollToSelectedTab(index); + }, + ); + } +} + +/// Stateful TV tab button that can track its own focus state +class _TVTabButton extends StatefulWidget { + final GlobalKey tabKey; + final int index; + final String title; + final bool isSelected; + final BracketController controller; + final int tvRowOffset; + final VoidCallback onPressed; + + const _TVTabButton({ + required this.tabKey, + required this.index, + required this.title, + required this.isSelected, + required this.controller, + required this.tvRowOffset, + required this.onPressed, + }); + + @override + State<_TVTabButton> createState() => _TVTabButtonState(); +} + +class _TVTabButtonState extends State<_TVTabButton> { + bool _isFocused = false; + + @override + Widget build(BuildContext context) { + // Get focus styling from controller with fallback chain + // Priority: tabStyles > Theme > Provider > Widget styles > Default (same as box_wrapper.dart) + final theme = Theme.of(context); + final themeExtension = theme.extension(); + final tvFocusTheme = themeExtension?.tvFocusTheme; + final appPrimaryColor = theme.colorScheme.primary; + final externalProvider = TVFocusProviderScope.maybeOf(context); + + // Priority: focusBorderColor > Theme > provider > borderColor > app primary + final focusBorderColor = widget.controller.tabFocusColor ?? + tvFocusTheme?.focusBorderColor ?? + externalProvider?.focusBorderColor ?? + widget.controller.tabBorderColor ?? + appPrimaryColor; + // Priority: focusBorderWidth > Theme > provider > borderWidth > default (2.0) + final focusBorderWidth = widget.controller.tabFocusBorderWidth ?? + tvFocusTheme?.focusBorderWidth ?? + externalProvider?.focusBorderWidth ?? + widget.controller.tabBorderWidth ?? + 2.0; + // Priority: focusBorderRadius > Theme > provider > borderRadius > default (8.0) + final borderRadius = widget.controller.tabFocusBorderRadius?.getValue() ?? + (tvFocusTheme?.focusBorderRadius != null + ? BorderRadius.circular(tvFocusTheme!.focusBorderRadius!) + : null) ?? + (externalProvider?.focusBorderRadius != null + ? BorderRadius.circular(externalProvider!.focusBorderRadius!) + : null) ?? + widget.controller.tabBorderRadius?.getValue() ?? + BorderRadius.circular(8); + + // Determine background color based on focus and selection state + // Priority: focused > selected > default + Color? backgroundColor; + if (_isFocused && widget.controller.tabFocusBackgroundColor != null) { + backgroundColor = widget.controller.tabFocusBackgroundColor; + } else if (widget.isSelected) { + backgroundColor = widget.controller.tabSelectedBackgroundColor; + } else { + backgroundColor = widget.controller.tabBackgroundColor; + } + + // Determine text style based on focus and selection state + // Priority: focused > selected > default + TextStyle? textStyle; + if (_isFocused && widget.controller.tabFocusTextStyle != null) { + textStyle = widget.controller.tabFocusTextStyle; + } else if (widget.isSelected) { + textStyle = widget.controller.tabSelectedStyle; + } else { + textStyle = widget.controller.tabTextStyle; + } + + // Border color: use focus color when focused, transparent otherwise + // Always render border to prevent size jerk + final borderColor = _isFocused ? focusBorderColor : Colors.transparent; + + return TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + widget.tvRowOffset.toDouble(), // Tab row from tvOptions + order: widget.index.toDouble(), + isRowEntryPoint: widget.isSelected, // Selected tab is entry point + ), + child: Container( + padding: EdgeInsets.only(left: widget.controller.tabGap), + child: Focus( + onFocusChange: (hasFocus) { + setState(() { + _isFocused = hasFocus; + }); + }, + child: ElevatedButton( + key: widget.tabKey, + onPressed: widget.onPressed, + style: ElevatedButton.styleFrom( + padding: widget.controller.tabPadding, + backgroundColor: backgroundColor, + // Disable Material focus/hover overlay to only show our custom border + overlayColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: BorderSide( + color: borderColor, + width: focusBorderWidth, + ), + ), + ), + child: Text( + widget.title, + style: textStyle, + ), + ), + ), + ), + ); } } @@ -340,12 +637,18 @@ class BracketsPage extends StatefulWidget { final List data; final PageController pageController; final BracketController controller; + final int tvRowOffset; + final int prevColumnIndex; + final ValueChanged? onPrevColumnIndexChanged; const BracketsPage({ super.key, required this.data, required this.pageController, required this.controller, + this.tvRowOffset = 0, + this.prevColumnIndex = 0, + this.onPrevColumnIndexChanged, }); @override @@ -362,7 +665,6 @@ class CustomScrollNotification extends Notification { } class _BracketsPageState extends State { - int _prevColumnIndex = 0; late List _scrollControllers; @override @@ -373,14 +675,75 @@ class _BracketsPageState extends State { } void _onPageChanged(int index) async { - _scrollControllers[index].animateTo(0.0, - duration: const Duration(milliseconds: 600), curve: Curves.decelerate); - _scrollControllers[index].animateTo(0.1, - duration: const Duration(milliseconds: 10), curve: Curves.decelerate); + // Only animate scroll if controller is attached + if (index < _scrollControllers.length && + _scrollControllers[index].hasClients) { + _scrollControllers[index].animateTo(0.0, + duration: const Duration(milliseconds: 600), + curve: Curves.decelerate); + _scrollControllers[index].animateTo(0.1, + duration: const Duration(milliseconds: 10), curve: Curves.decelerate); + } - setState(() { - _prevColumnIndex = index; - }); + // Note: prevColumnIndex is managed by parent (_BracketsViewState) and updated: + // 1. Via _animateToPage when tabs are clicked + // 2. Via onPrevColumnIndexChanged callback when keyboard navigation occurs + // We don't update it here because onPageChanged fires unreliably with small + // viewportFraction and padEnds=false (doesn't fire for clamped pages like page 3). + } + + /// Find and focus an item in the current page at the given row. + /// If the row doesn't exist (e.g., row 8 in Quarter Finals), clamp to available rows. + void _focusRowInCurrentPage(int targetRow, int columnIndex) { + // Number of matches in this round determines max row + final matchCount = widget.data[columnIndex].matches.data; + final evalData = + widget.data[columnIndex].localScope.dataContext.eval(matchCount); + final numMatches = (evalData as List?)?.length ?? 1; + + // Clamp targetRow to available match rows + // Tabs are at tvRowOffset, matches start at tvRowOffset + 1 + final matchRowStart = widget.tvRowOffset + 1; + final clampedRow = + targetRow.clamp(matchRowStart, matchRowStart + numMatches - 1); + + // Find the focusable item with this row and column (order) + // We find the DEEPEST focusable descendant that has this TVFocusOrder, + // as this corresponds to the innermost Focus widget with visual styling. + final root = FocusManager.instance.rootScope; + FocusNode? bestMatch; + int bestDepth = -1; + + for (final focusNode in root.descendants) { + if (focusNode.context == null) continue; + + final focusTraversalOrder = focusNode.context + ?.findAncestorWidgetOfExactType(); + if (focusTraversalOrder?.order is TVFocusOrder) { + final order = focusTraversalOrder!.order as TVFocusOrder; + + // Match by row and order (column = roundIndex) + if (order.row.toInt() == clampedRow && + order.order.toInt() == columnIndex) { + // Calculate depth of this node (deeper = better for visual styling) + int depth = 0; + FocusNode? parent = focusNode.parent; + while (parent != null) { + depth++; + parent = parent.parent; + } + + if (depth > bestDepth) { + bestDepth = depth; + bestMatch = focusNode; + } + } + } + } + + if (bestMatch != null) { + bestMatch.requestFocus(); + } } @override @@ -391,12 +754,103 @@ class _BracketsPageState extends State { super.dispose(); } + /// Handle LEFT/RIGHT key events to animate PageView. + /// Match cards set delegateHorizontalNavigation: true, so horizontal keys bubble up here. + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + // Use widget.prevColumnIndex instead of pageController.page because with small viewportFraction + // and padEnds=false, pageController.page gets clamped (e.g., max 1.5 with 4 pages at 0.4 fraction) + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + final currentPage = widget.prevColumnIndex; + if (currentPage < widget.data.length - 1) { + final focusRow = _getCurrentFocusedRow(node); + final targetPage = currentPage + 1; + + // Notify parent to update prevColumnIndex BEFORE animation starts. + // This ensures correct navigation even if onPageChanged doesn't fire (clamped pages). + widget.onPrevColumnIndexChanged?.call(targetPage); + + widget.pageController + .animateToPage( + targetPage, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ) + .then((_) { + // After animation completes, transfer focus to the new page + if (focusRow != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusRowInCurrentPage(focusRow, targetPage); + }); + } + }); + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + final currentPage = widget.prevColumnIndex; + if (currentPage > 0) { + final focusRow = _getCurrentFocusedRow(node); + final targetPage = currentPage - 1; + + // Notify parent to update prevColumnIndex BEFORE animation starts. + // This ensures correct navigation even if onPageChanged doesn't fire (clamped pages). + widget.onPrevColumnIndexChanged?.call(targetPage); + + widget.pageController + .animateToPage( + targetPage, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ) + .then((_) { + // After animation completes, transfer focus to the new page + if (focusRow != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusRowInCurrentPage(focusRow, targetPage); + }); + } + }); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + } + + /// Extract the current focused row from the TVFocusOrder in the focus tree. + int? _getCurrentFocusedRow(FocusNode node) { + // Use primaryFocus instead of the passed node, because the passed node + // is the FocusScope's node, not the actually focused child + FocusNode? current = FocusManager.instance.primaryFocus; + while (current != null) { + final context = current.context; + if (context != null) { + final focusTraversalOrder = + context.findAncestorWidgetOfExactType(); + if (focusTraversalOrder?.order is TVFocusOrder) { + final order = focusTraversalOrder!.order as TVFocusOrder; + // Return the row as-is (matches are at tvRowOffset + 1 + matchIndex) + return order.row.toInt(); + } + } + current = current.parent; + } + return null; + } + @override Widget build(BuildContext context) { - return NotificationListener( + Widget pageView = NotificationListener( onNotification: (CustomScrollNotification notification) { int currentPage = widget.pageController.page?.round() ?? 0; - _scrollControllers[currentPage + 1].jumpTo(notification.scrollPosition); + // Sync scroll to ALL columns after the current one (not just the next) + // This ensures Semi Finals and Final scroll with 8th Finals and Quarter Finals + for (int i = currentPage + 1; i < _scrollControllers.length; i++) { + if (_scrollControllers[i].hasClients) { + _scrollControllers[i].jumpTo(notification.scrollPosition); + } + } return true; }, child: PageView.builder( @@ -409,13 +863,24 @@ class _BracketsPageState extends State { controller: widget.controller, roundData: widget.data[columnIndex], columnIndex: columnIndex, - prevColumnIndex: _prevColumnIndex, + prevColumnIndex: widget.prevColumnIndex, totalColumns: widget.data.length, scrollController: _scrollControllers[columnIndex], + tvRowOffset: widget.tvRowOffset, ); }, ), ); + + // On TV, wrap with FocusScope to catch delegated horizontal key events + if (Device().isTV) { + return FocusScope( + onKeyEvent: _handleKeyEvent, + child: pageView, + ); + } + + return pageView; } } @@ -426,6 +891,7 @@ class BracketsColumnPage extends StatefulWidget { final int totalColumns; final BracketController controller; final ScrollController scrollController; + final int tvRowOffset; const BracketsColumnPage({ super.key, @@ -435,6 +901,7 @@ class BracketsColumnPage extends StatefulWidget { required this.totalColumns, required this.controller, required this.scrollController, + this.tvRowOffset = 0, }); @override @@ -455,8 +922,6 @@ class _BracketsColumnPageState extends State { @override Widget build(BuildContext context) { - bool isNextColumn = widget.columnIndex == widget.prevColumnIndex + 1; - return NotificationListener( onNotification: (ScrollNotification notification) { if (notification is ScrollUpdateNotification) { @@ -480,45 +945,112 @@ class _BracketsColumnPageState extends State { final matchIndex = entry.key; final matchData = entry.value; + // Calculate vertical MARGIN to center cards between previous column's matches + // Uses prevColumnIndex for animation effect - stages "expand" as you navigate to them + // + // Key insight: In a Column, margin.top is RELATIVE to the previous element, + // not an absolute position from the top. So we need: + // - Match 0: margin = initialOffset (where to start from top) + // - Match i > 0: margin = gap between cards + // + // Where: initialOffset = cardHeight * 0.5 * (multiplier - 1) + // gap = cardHeight * (multiplier - 1) double topOffset = 0; if (widget.prevColumnIndex < widget.columnIndex) { - topOffset = topOffset + (matchCardHeight / 2); - if (matchIndex > 0) { - topOffset = topOffset + matchCardHeight / 2; + final distance = widget.columnIndex - widget.prevColumnIndex; + final multiplier = 1 << distance; // 2^distance + if (matchIndex == 0) { + // First match: position from top of container + topOffset = matchCardHeight * 0.5 * (multiplier - 1); + } else { + // Subsequent matches: gap between cards (relative to previous) + topOffset = matchCardHeight * (multiplier - 1); } } - widget.roundData.localScope.dataContext + // Create a CHILD scope for each match + final matchScope = widget.roundData.localScope.createChildScope(); + matchScope.dataContext .addDataContextById(widget.roundData.matches.name, matchData); + matchScope.dataContext + .addDataContextById('matchIndex', matchIndex); + matchScope.dataContext + .addDataContextById('roundIndex', widget.columnIndex); + + // Build the widget model and widget, then wrap in DataScopeWidget + // This allows the widget to access the scope's data context for expressions + final widgetModel = matchScope.buildWidgetModelFromDefinition( + widget.roundData.matches.template); + final templatedWidget = + matchScope.buildWidgetFromModel(widgetModel); + final cellWidget = DataScopeWidget( + scopeManager: matchScope, + child: templatedWidget, + ); + + // Wrap with TVFocusWidget directly in Dart (not via YAML tvOptions) + // This avoids scope evaluation timing issues - matchIndex/roundIndex + // are available here but not during YAML re-evaluation on rebuild. + // Note: Don't add extra Focus widget here - let box_wrapper's Focus + // handle visual styling. TVFocusWidget only provides ordering. + Widget matchWidget = cellWidget; + if (Device().isTV) { + matchWidget = TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + (widget.tvRowOffset + 1 + matchIndex) + .toDouble(), // matches start at tvRowOffset + 1 + order: widget.columnIndex.toDouble(), // column = roundIndex + isRowEntryPoint: + matchIndex == 0, // first match is entry point + delegateHorizontalNavigation: + true, // let bracket handle LEFT/RIGHT + ), + child: matchWidget, + ); + } - final cellWidget = widget.roundData.localScope - .buildWidgetFromDefinition(widget.roundData.matches.template); return AnimatedContainer( height: matchCardHeight, - width: MediaQuery.of(context).size.width * 0.6, + width: MediaQuery.of(context).size.width * + widget.controller.matchCardWidthFraction, duration: const Duration(milliseconds: 300), margin: EdgeInsets.only( - top: topOffset, left: !isNextColumn ? 0 : 15), + top: topOffset, + // Left margin animates with navigation - expands when moving forward, collapses when moving back + left: widget.prevColumnIndex < widget.columnIndex + ? widget.controller.connectorLength + : 0), child: CustomPaint( painter: BracketPainter( isTopBracket: widget.columnIndex + 1 == widget.totalColumns ? null : !(matchIndex % 2 == 0), + // Uses prevColumnIndex for animation - left line appears as you navigate forward showLeftBorder: widget.prevColumnIndex < widget.columnIndex, lineColor: widget.controller.lineColor ?? Colors.black, borderColor: widget.controller.borderColor ?? Colors.black, lineWidth: widget.controller.lineWidth ?? 2.0, borderWidth: widget.controller.borderWidth?.toDouble() ?? 2.0, + connectorLength: widget.controller.connectorLength, + columnIndex: widget.columnIndex, + prevColumnIndex: widget.prevColumnIndex, ), - child: Padding( - padding: const EdgeInsets.all(2.0), - child: cellWidget, - ), + child: matchWidget, ), ); }), - if (isNextColumn) SizedBox(height: matchCardHeight), + // Add bottom padding to equalize content heights across columns + // This ensures all columns can scroll together without one running out of content + // Bottom padding = first match's top margin = cardHeight * 0.5 * (multiplier - 1) + if (widget.prevColumnIndex < widget.columnIndex) ...[ + Builder(builder: (context) { + final distance = widget.columnIndex - widget.prevColumnIndex; + final multiplier = 1 << distance; + final bottomPadding = matchCardHeight * 0.5 * (multiplier - 1); + return SizedBox(height: bottomPadding); + }), + ], ], ), ), @@ -526,13 +1058,30 @@ class _BracketsColumnPageState extends State { } } +/// Paints bracket connector lines between tournament matches. +/// +/// Draws: +/// - Border rectangle around the match card +/// - Right-side horizontal + vertical connector (toward next round) +/// - Left-side horizontal connector (from previous round) +/// +/// [columnIndex] - The index of the current column being rendered. +/// [prevColumnIndex] - The index of the currently focused/visible page. +/// Used to calculate dynamic vertical line length when columns are "expanded" +/// (i.e., when viewing later rounds where card spacing increases). class BracketPainter extends CustomPainter { + // Baseline connector length used as default + static const _baselineConnectorLength = 25.0; + final bool? isTopBracket; final bool showLeftBorder; final Color lineColor; final double lineWidth; final Color borderColor; final double borderWidth; + final double connectorLength; + final int columnIndex; + final int prevColumnIndex; BracketPainter({ this.isTopBracket, @@ -541,6 +1090,9 @@ class BracketPainter extends CustomPainter { this.lineWidth = 2.0, this.borderColor = Colors.black, this.borderWidth = 2.0, + this.connectorLength = _baselineConnectorLength, + this.columnIndex = 0, + this.prevColumnIndex = 0, }); @override @@ -560,11 +1112,24 @@ class BracketPainter extends CustomPainter { if (isTopBracket != null) { final startPoint = Offset(size.width, size.height / 2); - final endPoint = Offset(size.width + 25, size.height / 2); + final endPoint = Offset(size.width + connectorLength, size.height / 2); canvas.drawLine(startPoint, endPoint, linePaint); - const double verticalLength = 40; final verticalStartPoint = endPoint; + // Calculate vertical line length based on card spacing + // Cards double in spacing with each column distance + // Vertical line needs to reach halfway to the adjacent card + final cardHeight = size.height; + double verticalLength; + if (prevColumnIndex < columnIndex) { + // When expanded, cards have gaps - use spacing multiplier + final distance = columnIndex - prevColumnIndex; + final multiplier = 1 << distance; // 2^distance + verticalLength = cardHeight * 0.5 * multiplier; + } else { + // Non-expanded: cards stacked with no gap, use half card height + verticalLength = cardHeight * 0.5; + } final verticalEndPoint = isTopBracket! ? Offset(endPoint.dx, endPoint.dy - verticalLength) : Offset(endPoint.dx, endPoint.dy + verticalLength); @@ -572,13 +1137,110 @@ class BracketPainter extends CustomPainter { } if (showLeftBorder) { final leftStartPoint = Offset(0, size.height / 2); - final leftEndPoint = Offset(-25, size.height / 2); + final leftEndPoint = Offset(-connectorLength, size.height / 2); canvas.drawLine(leftStartPoint, leftEndPoint, linePaint); } } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; + bool shouldRepaint(covariant BracketPainter oldDelegate) => + oldDelegate.columnIndex != columnIndex || + oldDelegate.prevColumnIndex != prevColumnIndex || + oldDelegate.isTopBracket != isTopBracket || + oldDelegate.showLeftBorder != showLeftBorder || + oldDelegate.connectorLength != connectorLength || + oldDelegate.lineColor != lineColor || + oldDelegate.lineWidth != lineWidth || + oldDelegate.borderColor != borderColor || + oldDelegate.borderWidth != borderWidth; +} + +/// Simple TV focus provider for the Bracket widget. +/// Only purpose: Set handlesHorizontalScroll = true to prevent horizontal jerk +/// when navigating UP/DOWN (stops box_wrapper from calling Scrollable.ensureVisible). +/// +/// Horizontal navigation is handled by: +/// 1. Match cards set delegateHorizontalNavigation: true in YAML +/// 2. BracketsPage FocusScope catches LEFT/RIGHT keys and animates PageView +class _BracketTVFocusProvider implements TVFocusProvider { + // Singleton - no state needed + static final _instance = _BracketTVFocusProvider._(); + factory _BracketTVFocusProvider() => _instance; + _BracketTVFocusProvider._(); + + @override + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + String? focusGroup, + FocusNode? primaryFocusNode, + KeyEventResult Function(FocusNode node)? onBackPressed, + VoidCallback? onRightEdge, + VoidCallback? onLeftEdge, + VoidCallback? onTopEdge, + VoidCallback? onBottomEdge, + }) { + // DON'T wrap with TVFocusWidget - bracket.dart already handles TV navigation. + // box_wrapper has already applied focus styling (backgroundColor, focusBorderColor, etc.) + // from YAML tvOptions. Just return the styled child as-is. + return child; + } + + /// The bracket handles horizontal scrolling via PageView page changes. + /// This prevents box_wrapper from calling Scrollable.ensureVisible() + /// which would cause horizontal jerk when navigating UP/DOWN. + @override + bool get handlesHorizontalScroll => true; + + @override + double get rowOffset => 0; + + @override + double get orderOffset => 0; + + @override + Color? get focusBorderColor => null; + + @override + double? get focusBorderWidth => null; + + @override + double? get focusBorderRadius => 0; + + @override + int? get focusAnimationDurationMs => null; + + @override + void dispose() {} + + @override + void requestFocusAt(BuildContext context, double row, + [double? order, String? focusGroup]) { + const TVFocusOrder(0).requestFocusAt(context, row, order, focusGroup); + } + + @override + void requestFocusByEdge( + BuildContext context, { + required TVFocusDirection direction, + String? targetFocusGroup, + double? targetRow, + double? targetOrder, + double? currentRow, + double? currentOrder, + }) { + const TVFocusOrder(0).requestFocusByEdge( + context, + direction: direction, + targetFocusGroup: targetFocusGroup, + targetRow: targetRow, + targetOrder: targetOrder, + currentRow: currentRow, + currentOrder: currentOrder, + ); } } diff --git a/modules/ensemble/doc/tv_developer_guide.md b/modules/ensemble/doc/tv_developer_guide.md new file mode 100644 index 000000000..adb685d3e --- /dev/null +++ b/modules/ensemble/doc/tv_developer_guide.md @@ -0,0 +1,1296 @@ +# Ensemble TV Complete Guide + +Comprehensive documentation for TV/D-pad navigation in the Ensemble framework, including YAML patterns, framework architecture, and flutter_pca integration. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Architecture](#2-architecture) +3. [tvOptions Property Reference](#3-tvoptions-property-reference) +4. [YAML Patterns & Examples](#4-yaml-patterns--examples) +5. [Carousel TV Implementation](#5-carousel-tv-implementation) +6. [ListView Scrollbar](#6-listview-scrollbar) +7. [Host App Integration (flutter_pca)](#7-host-app-integration-flutter_pca) +8. [Focus Styling](#8-focus-styling) +9. [Common UI Patterns](#9-common-ui-patterns) +10. [Pitfalls to Avoid](#10-pitfalls-to-avoid) +11. [Testing Checklist](#11-testing-checklist) +12. [Troubleshooting](#12-troubleshooting) +13. [Bracket TV Implementation](#bracket-tv-implementation) + +--- + +## 1. Overview + +The Ensemble framework provides comprehensive TV support through: + +- **2D Grid Navigation** - Row/order based focus traversal for D-pad +- **Pluggable Provider Pattern** - Host apps inject their own focus system +- **Netflix-Style Scrolling** - Fixed focus position with auto-scroll +- **Flexible Styling** - Multiple override levels for focus indicators +- **Carousel Support** - Horizontal slide navigation with autoplay control + +### Key Concepts + +| Concept | Description | +| ----------------------- | ------------------------------------------------------ | +| **Row** | Vertical position in focus grid (0, 1, 2, ...) | +| **Order** | Horizontal position within a row (0, 1, 2, ...) | +| **Entry Point** | Preferred focus target when entering a row | +| **Fixed Focus Scroll** | Netflix-style scrolling where focused item stays fixed | +| **Delegate Navigation** | Passing key events to parent (for carousels) | + +### Row/Order Navigation Grid + +``` + order=0 order=1 order=2 order=3 + ┌──────────┬──────────┬──────────┬──────────┐ +row=0 │ [Back] │ │ │ │ ← D-pad LEFT/RIGHT + ├──────────┼──────────┼──────────┼──────────┤ +row=1 │ [Card 1] │ [Card 2] │ [Card 3] │ [Card 4] │ ← Horizontal list + ├──────────┼──────────┼──────────┼──────────┤ +row=2 │ [Item A] │ [Item B] │ │ │ ← Grid row + ├──────────┼──────────┼──────────┼──────────┤ +row=3 │ [Item C] │ [Item D] │ │ │ + └──────────┴──────────┴──────────┴──────────┘ + ↑ + D-pad UP/DOWN moves between rows +``` + +**Navigation Rules:** +- **UP/DOWN**: Move to adjacent row, land on entry point or nearest order +- **LEFT/RIGHT**: Move within same row to adjacent order +- **isRowEntryPoint**: Marks preferred landing spot when entering row + +--- + +## 2. Architecture + +### Core TV Framework Files + +| File | Purpose | +| -------------------------- | ---------------------------------------------------- | +| `tv_focus_provider.dart` | Abstract interface for host app integration | +| `tv_focus_widget.dart` | Built-in D-pad navigation handler with edge handlers | +| `tv_focus_order.dart` | Focus coordinates (row/order) and TVFocusScope | +| `tv_focus_theme.dart` | Styling configuration | +| `tv_scrollbar_widget.dart` | Focusable scrollbar for ListView on TV | + +### Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Host App (flutter_pca) │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ TV Top Navigation Bar │ │ +│ │ [Home] [Live] [Movies] [Series] [Sports] [Apps] │ │ +│ │ ↑ Order 5 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Sports Tab Content │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ TVFocusProviderScope │ │ │ +│ │ │ ┌───────────────────────────────────────────────┐ │ │ │ +│ │ │ │ PageFocusProvider │ │ │ │ +│ │ │ │ - rowOffset: 1.0 (below tab bar) │ │ │ │ +│ │ │ │ - orderOffset: 5.0 (Sports tab align) │ │ │ │ +│ │ │ └───────────────────────────────────────────────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌───────────────────────────────────────────────┐ │ │ │ +│ │ │ │ EnsembleScreenRenderer │ │ │ │ +│ │ │ │ - Renders Ensemble YAML UI definitions │ │ │ │ +│ │ │ │ - Widgets wrapped with TVFocusWidget │ │ │ │ +│ │ │ └───────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Focus Grid Alignment + +``` +flutter_pca Tab Bar (Row 0): + [Home:0] [Live:1] [Movies:2] [Series:3] [Watchlist:4] [Sports:5] [Apps:6] + +Ensemble Content (Row 1+, aligned with Sports at order 5): + [Match1:5] [Match2:6] [Match3:7] [Match4:8] + +↑ UP from Ensemble → Sports tab +↓ DOWN from Sports tab → Ensemble content +``` + +--- + +## 3. tvOptions Property Reference + +### Complete tvOptions Structure + +```yaml +tvOptions: + # Navigation Properties (Required for TV focus) + row: 0 # Vertical position in focus grid + order: 0 # Horizontal position within row + isRowEntryPoint: true # Preferred entry point when navigating into row + + # Focus Indicator Styling (Optional) + focusBorderRadius: 16 # Border radius for focus indicator (pixels) + focusBorderColor: 0xFF00AAFF # Focus indicator border color + focusBorderWidth: 2 # Focus indicator border width (pixels) + + # Focused State Styling (Optional - widget appearance when focused) + backgroundColor: 0xFF1A1A1A # Background color when focused + backgroundGradient: # Background gradient when focused + colors: [0xFF1A1A1A, 0xFF2A2A2A] + borderColor: 0xFFFFFFFF # Border color when focused + borderWidth: 2 # Border width when focused + borderRadius: 8 # Border radius when focused + boxShadow: # Box shadow when focused + color: 0x40000000 + offset: [0, 4] + blur: 8 + opacity: 1.0 # Opacity when focused (0.0 to 1.0) + elevation: 4 # Elevation when focused (0 to 24) + scale: 1.05 # Scale factor when focused (e.g., 1.05 = 5% larger) + padding: 12 # Padding when focused + margin: 8 # Margin when focused + + # Scroll Behavior (Optional - for horizontal lists) + fixedFocusScroll: true # Enable Netflix-style scrolling + fixedFocusOffset: 48 # Offset from left edge (pixels) + verticalScrollPadding: 100 # Extra padding when scrolling vertically + scrollAnimationDuration: 200 # Scroll animation duration (ms) + scrollAnimationCurve: easeOut # Animation curve + horizontalScrollPadding: 16 # Horizontal padding for visibility checks + + # Horizontal Navigation Control (for carousels) + delegateHorizontalNavigation: true # Delegate LEFT/RIGHT to parent FocusScope + lockHorizontalNavigation: true # Block LEFT/RIGHT at row boundaries + + # Carousel-Specific (on Carousel widget) + interceptHorizontalNav: true # Smart edge detection for LEFT/RIGHT + pauseAutoplayOnFocus: true # Pause autoplay when focused + restoreFocusOnPageChange: true # Restore focus after slide change + + # ListView Scrollbar (on ListView widget) + scrollbarOptions: + position: right # 'left' or 'right' + color: 0xFF666666 # Color when not focused + focusedColor: 0xFFFFFFFF # Color when focused + width: 3 # Width when not focused (pixels) + focusedWidth: 6 # Width when focused (pixels) + radius: 4 # Border radius of scrollbar + thumbHeight: 40 # Fixed thumb height (pixels) +``` + +### Property Details + +#### Navigation Properties + +| Property | Type | Default | Description | +| -------------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **row** | `double` | `null` | **Required**. Vertical position in the focus grid (0, 1, 2, ...). Items with the same row navigate horizontally with LEFT/RIGHT. | +| **order** | `double` | `0` | Horizontal position within the row (0, 1, 2, ...). Lower values = more left. Must be unique within a row. | +| **isRowEntryPoint** | `bool` | `false` | Marks this item as the preferred entry point when navigating INTO this row from another row. | +| **delegateHorizontalNavigation** | `bool` | `false` | When `true`, LEFT/RIGHT events are delegated to parent FocusScope. Use for items inside carousels. | +| **lockHorizontalNavigation** | `bool` | `false` | When `true`, prevents horizontal navigation from escaping row at boundaries. | + +#### Focus Indicator Styling + +These properties control the **focus indicator border** that appears around focused widgets. + +| Property | Type | Default | Description | +| --------------------- | -------- | ------------- | ---------------------------------------------------------------------------------------------- | +| **focusBorderColor** | `Color` | theme default | Custom color for focus indicator border. Accepts hex (0xFF00AAFF). | +| **focusBorderWidth** | `double` | `3.0` | Custom border width for focus indicator (pixels). | +| **focusBorderRadius** | `double` | theme default | Custom border radius for focus indicator. Use `22` for 44px circular buttons, `100` for pills. | + +#### Focused State Styling + +These properties change the **widget's appearance** when focused (not the focus indicator, but the widget itself). When unfocused, the widget uses its normal styles. + +| Property | Type | Default | Description | +| ---------------------- | -------------- | ------- | -------------------------------------------------------------------------------- | +| **backgroundColor** | `Color` | `null` | Background color when focused. Overrides widget's normal backgroundColor. | +| **backgroundGradient** | `Gradient` | `null` | Background gradient when focused. Overrides widget's normal backgroundGradient. | +| **borderColor** | `Color` | `null` | Border color when focused (widget's own border, not the focus indicator). | +| **borderWidth** | `int` | `null` | Border width when focused (widget's own border). | +| **borderRadius** | `BorderRadius` | `null` | Border radius when focused. | +| **boxShadow** | `BoxShadow` | `null` | Box shadow when focused. Accepts shadow composite (color, offset, blur, spread). | +| **opacity** | `double` | `null` | Opacity when focused (0.0 to 1.0). Use to fade/brighten widgets. | +| **elevation** | `int` | `null` | Material elevation when focused (0 to 24). Creates shadow depth. | +| **scale** | `double` | `null` | Scale factor when focused (e.g., 1.05 = 5% larger, 0.95 = 5% smaller). | +| **padding** | `EdgeInsets` | `null` | Padding when focused. Overrides widget's normal padding. | +| **margin** | `EdgeInsets` | `null` | Margin when focused. Overrides widget's normal margin. | + +#### Scroll Behavior + +| Property | Type | Default | Description | +| --------------------------- | -------- | --------- | ----------------------------------------------------------------------------------------- | +| **fixedFocusScroll** | `bool` | `false` | Netflix-style scrolling where focused item stays at fixed position while content scrolls. | +| **fixedFocusOffset** | `double` | `48.0` | Offset from left edge where focused item stays during fixed focus scrolling. | +| **verticalScrollPadding** | `double` | `0.0` | Extra padding when auto-scrolling vertically to keep focused item visible. | +| **horizontalScrollPadding** | `double` | `16.0` | Horizontal padding for visibility checks during scrolling. | +| **scrollAnimationDuration** | `int` | `200` | Duration of scroll animations in milliseconds. | +| **scrollAnimationCurve** | `String` | `easeOut` | Animation curve: easeIn, easeOut, easeInOut, linear, decelerate, ease. | + +#### Carousel-Specific Properties + +| Property | Type | Default | Description | +| ---------------------------- | ------ | ------- | ------------------------------------------------------------ | +| **interceptHorizontalNav** | `bool` | `false` | Smart edge detection for LEFT/RIGHT navigation in carousels. | +| **pauseAutoplayOnFocus** | `bool` | `false` | Pause carousel autoplay when any element within has focus. | +| **restoreFocusOnPageChange** | `bool` | `false` | Restore focus after carousel slide change. | + +#### ListView Scrollbar Properties + +These properties are set under `tvOptions.scrollbarOptions` on ListView widgets. + +| Property | Type | Default | Description | +| ---------------- | -------- | ------------ | -------------------------------------------------------------- | +| **position** | `String` | `'right'` | Scrollbar position: `'left'` or `'right'`. | +| **color** | `Color` | `0xFF666666` | Scrollbar color when not focused. | +| **focusedColor** | `Color` | `0xFFFFFFFF` | Scrollbar color when focused. | +| **width** | `int` | `3` | Scrollbar width in pixels when not focused. | +| **focusedWidth** | `int` | `6` | Scrollbar width in pixels when focused (wider for visibility). | +| **radius** | `int` | `4` | Border radius of scrollbar corners. | +| **thumbHeight** | `int` | `40` | Fixed height of scrollbar thumb in pixels. | +| **autofocus** | `bool` | `false` | Auto-focus scrollbar on mount (rarely needed). | + +### Where to Apply tvOptions + +Apply `tvOptions` in the `styles` section of focusable widgets: + +```yaml +Column: + styles: + tvOptions: + row: 0 + order: 0 + onTap: + navigateBack: +``` + +**Important**: Only widgets with `onTap` handlers OR form widgets (Switch, TextInput, etc.) can receive focus. + +--- + +## 4. YAML Patterns & Examples + +### Pattern 1: Simple List (One Focusable per Item) + +```yaml +item-template: + data: ${items} + name: item + indexId: itemIndex + template: + Column: + styles: + tvOptions: + row: 1 + order: ${itemIndex} + isRowEntryPoint: ${itemIndex == 0} + onTap: ... +``` + +### Pattern 2: Multiple Focusables per Item (CRITICAL) + +When each list item has MULTIPLE focusable elements, multiply the index: + +```yaml +# CORRECT - No conflicts +Switch: + styles: + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2} # Item 0: order 0, Item 1: order 2 +Delete: + styles: + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2 + 1} # Item 0: order 1, Item 1: order 3 +``` + +**Formula**: For N focusable elements per item: + +- Element 0: `order: ${index * N}` +- Element 1: `order: ${index * N + 1}` +- Element 2: `order: ${index * N + 2}` + +### Pattern 3: Netflix-Style Horizontal List + +```yaml +Row: + styles: + scrollable: true + gap: 8 + item-template: + data: ${mediaItems} + name: item + indexId: mediaIndex + template: + MediaCard: + inputs: + tvOptions: + row: 5 + order: ${mediaIndex} + isRowEntryPoint: ${mediaIndex == 0} + fixedFocusScroll: true + fixedFocusOffset: 48 + lockHorizontalNavigation: true +``` + +### Pattern 4: Grid Layout + +```yaml +item-template: + data: ${items} + indexId: itemIndex + template: + Card: + styles: + tvOptions: + row: ${1 + Math.floor(itemIndex / 4)} # Row changes every 4 items + order: ${itemIndex % 4} # 0, 1, 2, 3, 0, 1, 2, 3... +``` + +### Pattern 5: Passing tvOptions to Custom Widgets + +**Widget Definition:** + +```yaml +Widget: + inputs: + - item + - tvOptions + body: + Column: + styles: + tvOptions: ${tvOptions} + onTap: ... +``` + +**Widget Usage:** + +```yaml +item-template: + data: ${items} + indexId: idx + template: + MyWidget: + inputs: + item: ${item} + tvOptions: + row: ${1 + idx} + order: 0 +``` + +--- + +## 5. Carousel TV Implementation + +### Carousel-Level tvOptions + +```yaml +Carousel: + layout: single + autoplay: true + autoplayInterval: 5000 + styles: + tvOptions: + row: 1 + interceptHorizontalNav: true # Smart edge detection + pauseAutoplayOnFocus: true # Pause when user navigates + restoreFocusOnPageChange: true # Restore focus after slide change +``` + +### Item-Level: delegateHorizontalNavigation + +For focusable elements inside carousel slides: + +```yaml +# Inside carousel slide +Button: + label: "Watch Now" + styles: + tvOptions: + row: 1 + order: 0 + delegateHorizontalNavigation: true # LEFT/RIGHT switch slides +``` + +### How Carousel Navigation Works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Carousel (interceptHorizontalNav: true) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Slide 1 │ │ +│ │ ┌──────────────────────┐ │ │ +│ │ │ [Button] │ ← delegateHorizontalNavigation: true │ │ +│ │ │ LEFT/RIGHT delegated │ │ │ +│ │ └──────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ When user presses LEFT/RIGHT: │ +│ 1. Button delegates to parent FocusScope │ +│ 2. Carousel intercepts the event │ +│ 3. Carousel switches to previous/next slide │ +│ 4. Focus is restored to new slide's button │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Complete Hero Carousel Example + +```yaml +HeroCarousel: + body: + Carousel: + layout: single + height: 400 + autoplay: true + autoplayInterval: 5 + enableLoop: true + indicatorType: circle + styles: + tvOptions: + pauseAutoplayOnFocus: true + interceptHorizontalNav: true + restoreFocusOnPageChange: true + item-template: + data: ${competitions} + name: competition + indexId: slideIndex + template: + HeroSlide: + inputs: + competition: ${competition} + slideIndex: ${slideIndex} + +HeroSlide: + inputs: + - competition + - slideIndex + body: + Stack: + styles: + width: ${device.width - 96} + height: 400 + margin: 0 48 + borderRadius: 12 + clipContent: true + children: + - Image: + source: ${competition.heroImage} + styles: + width: ${device.width - 96} + height: 400 + fit: cover + + - Column: + styles: + width: ${device.width - 96} + height: 400 + mainAxis: end + crossAxis: start + padding: 48 48 80 48 + gap: 12 + backgroundGradient: + colors: + - 0x00000000 + - 0x60000000 + - 0xcc000000 + - 0xff000000 + stops: + - 0.0 + - 0.40 + - 0.65 + - 0.85 + start: topCenter + end: bottomCenter + children: + - Text: + text: ${competition.title} + styles: + textStyle: + fontSize: 36 + fontWeight: bold + + - Text: + text: ${competition.description} + + - Button: + label: "View Competition" + styles: + margin: 8 0 0 0 + tvOptions: + row: 0 + order: ${slideIndex} + verticalScrollPadding: 400 + delegateHorizontalNavigation: true + onTap: + navigateScreen: + name: CompetitionDetails +``` + +### Key Difference: delegateHorizontalNavigation vs lockHorizontalNavigation + +| Property | Behavior | Use Case | +| ------------------------------ | ------------------------------------------------ | --------------------------- | +| `delegateHorizontalNavigation` | Event bubbles up to parent (carousel handles it) | Items inside carousels | +| `lockHorizontalNavigation` | Event is blocked/consumed (nothing happens) | Standalone horizontal lanes | + +--- + +## 6. ListView Scrollbar + +### Overview + +ListView on TV supports a focusable scrollbar that allows users to scroll content using D-pad navigation. The scrollbar: + +- Appears on the left or right edge of the ListView +- Becomes focusable via D-pad navigation (RIGHT to right-side scrollbar, LEFT to left-side scrollbar) +- Changes visual state when focused (color, width) +- Scrolls content with UP/DOWN keys when focused +- Works correctly with multi-column content layouts + +### Architecture: Edge Handlers + +The scrollbar uses **edge handlers** integrated into the TVFocusWidget grid system: + +``` +┌────────────────────────────────────────────────────────────────┐ +│ ListView │ +│ ┌──────────────────────────────────────────────────┐ ┌────┐ │ +│ │ TVFocusScope │ │ │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ S │ │ +│ │ │ Item (row=1, order=0) ─RIGHT→ Item (order=1) │→│ C │ │ +│ │ │ Item (row=2, order=0) ─RIGHT→ Item (order=1) │→│ R │ │ +│ │ │ Item (row=3, order=0) ─RIGHT→ Item (order=1) │→│ O │ │ +│ │ └────────────────────────────────────────────┘ │ │ L │ │ +│ │ ↑ │ │ L │ │ +│ │ onRightEdge: scrollbar.requestFocus() │ │ │ │ +│ └──────────────────────────────────────────────────┘ └────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +**How it works:** + +1. User navigates through grid items using D-pad +2. When at the rightmost item (e.g., order=1), pressing RIGHT triggers `onRightEdge` +3. `onRightEdge` callback requests focus on the scrollbar +4. Scrollbar receives focus and handles UP/DOWN for scrolling +5. LEFT returns focus back to content + +### Basic Usage + +```yaml +ListView: + styles: + expanded: true + tvOptions: + scrollbarOptions: + position: right # 'left' or 'right' + color: 0xFF666666 # Grey when not focused + focusedColor: 0xFFFFFFFF # White when focused + width: 3 # Thin when not focused + focusedWidth: 6 # Wider when focused + radius: 4 # Corner radius + thumbHeight: 40 # Thumb size + item-template: + data: ${items} + name: item + indexId: idx + template: + Column: + styles: + tvOptions: + row: ${1 + idx} + order: 0 + isRowEntryPoint: ${idx == 0} + onTap: + showToast: + message: Tapped ${item.title} +``` + +### Multi-Column Content + +The scrollbar correctly handles multi-column layouts. Users must navigate through all columns before reaching the scrollbar: + +```yaml +ListView: + styles: + tvOptions: + scrollbarOptions: + position: right + item-template: + data: ${items} + indexId: idx + template: + Row: + children: + # Left column (order 0) + - Column: + styles: + tvOptions: + row: ${1 + idx} + order: 0 + onTap: ... + + # Right column (order 1) + - Column: + styles: + tvOptions: + row: ${1 + idx} + order: 1 + onTap: ... +``` + +**Navigation flow:** + +``` +Item (order 0) ─RIGHT→ Item (order 1) ─RIGHT→ Scrollbar + │ +Item (order 0) ←LEFT─ Item (order 1) ←LEFT─ ─┘ +``` + +### Key Points + +- **Only on TV**: Scrollbar only renders on TV devices +- **Position**: Use `position: left` for left-side scrollbar (navigation reverses) +- **Multi-Column Safe**: Navigates through all columns before reaching scrollbar + +--- + +## 7. Host App Integration (flutter_pca) + +### TVFocusProvider Interface + +```dart +abstract class TVFocusProvider { + double get rowOffset; // Ensemble content starts at this row + double get orderOffset; // Ensemble content starts at this order + + Color? get focusBorderColor; + double? get focusBorderWidth; + double? get focusBorderRadius; + + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + KeyEventResult Function(FocusNode node)? onBackPressed, + bool disableHostScroll = true, + }); + + void dispose(); +} +``` + +### flutter_pca Implementation + +```dart +class PageFocusProvider implements TVFocusProvider { + @override + double get rowOffset => 1.0; // Below tab bar + + @override + double get orderOffset => 5.0; // Aligned with Sports tab + + @override + Color? get focusBorderColor => AppThemeManager.currentTheme.snowGrey; + + @override + double? get focusBorderWidth => 1.5; + + @override + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + KeyEventResult Function(FocusNode node)? onBackPressed, + bool disableHostScroll = true, + }) { + return PageFocusWidget( + focusOrder: PageFocusOrder.withOptions( + row, + order: order, + isRowEntryPoint: isRowEntryPoint, + lockHorizontalNavigation: lockHorizontalNavigation, + delegateHorizontalNavigation: delegateHorizontalNavigation, + disableHostScroll: disableHostScroll, + ), + goBackButtonHandled: onBackPressed, + disableHostScroll: disableHostScroll, + child: child, + ); + } +} +``` + +### Integration with EnsembleApp + +```dart +// In Sports tab +EnsembleWrapper( + tvFocusProvider: PageFocusProvider(), + child: EnsembleScreen(payload: payload), +) + +// Internally wraps with scope: +if (widget.tvFocusProvider != null) { + app = TVFocusProviderScope( + provider: widget.tvFocusProvider!, + child: app, + ); +} +``` + +### Files Changed for flutter_pca Integration + +| File | Description | +| ---------------------------------------------------------- | ------------------------------------------------- | +| `lib/screens/widgets/custom/page_focus_provider.dart` | Bridge between Ensemble and flutter_pca | +| `lib/screens/widgets/custom/pca_button.dart` | PageFocusWidget with delegateHorizontalNavigation | +| `lib/screens/ensemble/ensemble_wrapper.dart` | TVFocusProvider integration | +| `lib/screens/ensemble/platform.tv/ensemble.tv.screen.dart` | TV screen with focus scope | + +--- + +## 8. Focus Styling + +### Focus Indicator Styling Priority Chain + +The focus indicator border color, width, and radius follow this priority order: + +``` +1. Per-Widget Override (styles.tvOptions.focusBorderColor/focusBorderWidth/focusBorderRadius) + ↓ (if not set) +2. Theme Configuration (theme.yaml - Common.Tokens.TV.*) + ↓ (if not set) +3. TVFocusProvider (host app integration, e.g., flutter_pca) + ↓ (if not set) +4. Widget's Normal Styles (styles.borderColor/borderWidth/borderRadius) + ↓ (if not set) +5. Ensemble Defaults: + - Focus Color: App's primary color (Theme.of(context).colorScheme.primary) + - Border Width: 3.0px + - Border Radius: 8.0px + - Animation Duration: 150ms +``` + +**Source:** [box_wrapper.dart:799-850](../modules/ensemble/lib/widget/helpers/box_wrapper.dart) + +### Focused State Styling + +For focused state styling, there are two approaches: + +**1. Expression Bindings (Recommended)** - For most style properties: +```yaml +Column: + id: card + styles: + backgroundColor: "${card.hasFocus ? '0xFF2A2A2A' : '0xFF1A1A1A'}" + padding: "${card.hasFocus ? 16 : 12}" + tvOptions: + row: 1 + order: 0 +``` + +**2. tvOptions Properties** - For wrapper-based effects (scale, elevation, opacity): +```yaml +Column: + styles: + tvOptions: + row: 1 + order: 0 + scale: 1.05 # Zoom when focused + elevation: 8 # Shadow when focused + opacity: 1.0 # Full brightness when focused +``` + +**Note:** Use expression bindings (`${widget.hasFocus ? ... : ...}`) for backgroundColor, borderColor, padding, margin, and all text/icon styles. Use tvOptions only for scale, elevation, and opacity which require wrapper widgets. + +### Theme Configuration (theme.yaml) + +```yaml +Common: + Tokens: + TV: + focusBorderColor: 0xFF00AAFF + focusBorderWidth: 3 + focusBorderRadius: 8 + focusAnimationDuration: 150 +``` + +### Per-Widget Override (Focus Indicator) + +```yaml +Button: + label: "Special Button" + styles: + tvOptions: + row: 1 + order: 0 + focusBorderColor: 0xFFFF0000 # Red focus border + focusBorderWidth: 4 + focusBorderRadius: 24 +``` + +### Focused State Styling Example + +Using expression bindings with tvOptions wrapper effects: + +```yaml +Column: + id: card + styles: + # Expression bindings for regular styles + backgroundColor: "${card.hasFocus ? '0xFF2A2A2A' : '0xFF1A1A1A'}" + padding: "${card.hasFocus ? 16 : 12}" + tvOptions: + row: 1 + order: ${idx} + # Wrapper-based effects (can't use expression bindings) + scale: 1.05 # Grow 5% when focused + opacity: 1.0 # Full brightness when focused + elevation: 8 # Shadow when focused + children: + - Text: + text: "Card Title" + styles: + textStyle: + # Child widgets use parent's hasFocus + color: "${card.hasFocus ? '0xFFFFFFFF' : '0xFF888888'}" + fontSize: "${card.hasFocus ? 18 : 14}" + onTap: ... +``` + +| Approach | Properties | When to Use | +|----------|-----------|-------------| +| **Expression Bindings** | backgroundColor, borderColor, padding, margin, textStyle, etc. | Most style changes | +| **tvOptions** | scale, elevation, opacity | Wrapper-based effects only | + +### Focus Indicator vs Focused State + +**Focus Indicator** (focusBorderColor, focusBorderWidth, focusBorderRadius): + +- The **border** that appears around the focused widget +- Controlled by Ensemble's TV focus system +- Always a simple border overlay + +**Focused State Styling:** + +- Use **expression bindings** for most styles (backgroundColor, textStyle, etc.) +- Use **tvOptions** only for scale, elevation, opacity (wrapper-based effects) +- Stacks with focus indicator for rich effects + +**Combined Example:** + +```yaml +Button: + id: watchBtn + label: "${watchBtn.hasFocus ? 'WATCH NOW' : 'Watch Now'}" + styles: + backgroundColor: "${watchBtn.hasFocus ? '0xFF1E88E5' : '0xFF2196F3'}" + borderRadius: 8 + labelStyle: + fontWeight: "${watchBtn.hasFocus ? 'bold' : 'normal'}" + tvOptions: + row: 1 + order: 0 + # Focus indicator (white border around button) + focusBorderColor: 0xFFFFFFFF + focusBorderWidth: 3 + focusBorderRadius: 10 + # Wrapper-based effects + scale: 1.05 + elevation: 8 +``` + +Result when focused: + +1. Button grows 5% larger (scale: 1.05) +2. Button background changes to darker blue (expression binding) +3. Button text becomes bold and uppercase (expression binding) +4. Button gets 8px elevation shadow +5. **Then** white 3px focus border appears around it + +--- + +## 9. Common UI Patterns + +### Back Button (44x44 Circular) + +```yaml +- Column: + styles: + width: 44 + height: 44 + mainAxis: center + crossAxis: center + tvOptions: + row: 0 + order: 0 + focusBorderRadius: 22 + onTap: + navigateBack: + children: + - Image: + source: ${env.assets}${env.back_icon_png} + styles: + width: 44 + height: 44 + placeholderColor: transparent +``` + +### Switch in List + +```yaml +- Switch: + styles: + flexMode: none + maxWidth: 60 + width: 60 + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2} + value: ${item.notifications} + onChange: ... +``` + +### Dialog Focus Order + +```yaml +DialogWidget: + body: + Column: + children: + - Column: # Close button (row 0) + styles: + tvOptions: {row: 0, order: 0} + onTap: + closeAllDialogs: + - Column: # Content items (row 1+) + item-template: + data: ${items} + indexId: idx + template: + ItemWidget: + inputs: + tvOptions: {row: ${1 + idx}, order: 0} + - Button: # Save button (high row) + styles: + tvOptions: {row: 50, order: 0} +``` + +--- + +## 10. Pitfalls to Avoid + +### tvOptions on Wrong Element + +```yaml +# WRONG - Switch won't be focusable! +Column: + styles: + tvOptions: + row: 0 + children: + - Switch: + value: true + +# CORRECT - tvOptions on the Switch itself +Column: + children: + - Switch: + styles: + tvOptions: + row: 0 + value: true +``` + +### Missing flexMode in FlexRow + +```yaml +# WRONG - Column may expand unexpectedly +FlexRow: + children: + - Text: + styles: + flexMode: expanded + - Column: + styles: + width: 40 + onTap: ... + +# CORRECT - Explicitly set flexMode: none +FlexRow: + children: + - Text: + styles: + flexMode: expanded + - Column: + styles: + flexMode: none + width: 40 + onTap: ... +``` + +### Size Mismatch (Gap Between Border and Content) + +```yaml +# WRONG - 4px gap on each side! +Column: + styles: + width: 48 + height: 48 + tvOptions: + focusBorderRadius: 24 + children: + - Image: + styles: + width: 40 + height: 40 + +# CORRECT - Same size, no gap +Column: + styles: + width: 44 + height: 44 + tvOptions: + focusBorderRadius: 22 + children: + - Image: + styles: + width: 44 + height: 44 +``` + +--- + +## 11. Testing Checklist + +### Navigation + +- [ ] All focusable elements have unique (row, order) pairs +- [ ] D-pad UP/DOWN moves between rows correctly +- [ ] D-pad LEFT/RIGHT moves within rows correctly +- [ ] First item in each row has `isRowEntryPoint: true` (for horizontal lists) +- [ ] Back button is always row 0, order 0 +- [ ] Dialog focus is trapped within dialog + +### Focus Visual + +- [ ] Focus border has appropriate radius matching the widget shape +- [ ] No visual gap between focus border and widget content +- [ ] Focus color is visible against background + +### Scrolling (for fixedFocusScroll rows) + +- [ ] Content scrolls smoothly while focus stays fixed +- [ ] Focus moves correctly at list boundaries (first/last items) +- [ ] `fixedFocusOffset` positions the focused item appropriately +- [ ] Scroll position resets when re-entering the row + +### Carousel Navigation + +**With `delegateHorizontalNavigation: true` (single button per slide):** +- [ ] ALL LEFT/RIGHT switches slides immediately (no in-row focus movement) + +**With `interceptHorizontalNav: true` only (multiple buttons per slide):** +- [ ] LEFT/RIGHT moves focus between items within the same slide +- [ ] At leftmost/rightmost item, LEFT/RIGHT switches slides + +**Common checks:** +- [ ] Focus is restored to new slide's button after slide change +- [ ] Autoplay pauses when carousel item is focused +- [ ] Autoplay resumes when focus leaves carousel +- [ ] UP/DOWN exits carousel to adjacent rows correctly + +--- + +## 12. Troubleshooting + +### Focus not working on Ensemble widgets + +1. Verify `row` is set in `tvOptions` +2. Check if `TVFocusProviderScope` is wrapping content +3. Ensure widget has `onTap` handler or is a form widget +4. Verify `rowOffset` and `orderOffset` values in provider + +### Navigation skipping items + +1. Check for duplicate row/order values +2. Verify items are within same `FocusTraversalGroup` +3. Check `lockHorizontalNavigation` settings + +### Scroll not following focus + +1. Enable `fixedFocusScroll: true` +2. Set appropriate `fixedFocusOffset` +3. Check if host's `handlesHorizontalScroll` is interfering + +### Carousel slides not switching + +1. Verify `delegateHorizontalNavigation: true` on carousel items +2. Check `interceptHorizontalNav: true` on carousel +3. Ensure carousel's FocusScope is receiving bubbled events + +### Navigation conflicts with native content + +1. Ensure `EnsembleWrapper` is scoped correctly (inside tab, not top-level) +2. Check row/order alignment with host app's focus grid +3. Verify cross-boundary navigation is enabled + +### Scrollbar not receiving focus + +1. Verify `scrollbarOptions` is set under `tvOptions` (not directly in `styles`) +2. Check position value is `'left'` or `'right'` (string, not unquoted) +3. For multi-column: ensure you're at the rightmost/leftmost item before pressing the edge key +4. Check logs for `[TVFocusWidget] At right edge - calling onRightEdge handler` +5. Verify ListView has a scroll controller (automatic for most cases) + +### Scrollbar not scrolling content + +1. Verify ListView content exceeds viewport (scrollbar needs scrollable content) +2. Check if `scrollController.hasClients` is true +3. Look for errors in logs related to scroll position + +### Scrollbar appearing on wrong side + +1. Verify `position` property value: `'left'` or `'right'` +2. Check for typos in position value +3. Ensure quotes around position value in YAML + +--- + +## Row Number Guidelines + +| Screen Section | Recommended Row Range | +| ------------------------------- | --------------------- | +| Header (back button, actions) | 0 | +| Hero Carousel | 0 (carousel items) | +| Info/help buttons | 1 | +| Favorites row | 1 | +| Main content lists | 2-4 | +| Section headers with "View all" | 5, 7 | +| Section content rows | 6, 8 | +| Footer buttons | 50+ | + +**Note**: Leave gaps between sections to allow for future additions. + +--- + +## Bracket TV Implementation + +The `Bracket` widget (tournament brackets) has built-in TV D-pad navigation: + +- **Tabs** (round tabs) - Navigate with LEFT/RIGHT between tabs +- **Matches** - Navigate with UP/DOWN within a round, LEFT/RIGHT between rounds +- **Focus Row Preservation** - When moving between rounds, focus stays on the same relative row (clamped to available matches) + +### Bracket tvOptions + +```yaml +Bracket: + styles: + tvOptions: + row: 1 # Tab row, matches start at row+1 + tabStyles: + focusBorderRadius: 12 # Focus indicator radius for tabs + # focusBorderColor and focusBorderWidth follow the standard priority chain +``` + +### Match Card tvOptions + +Match cards inside brackets require `row: 0` for box_wrapper focus styling (actual row is set dynamically by bracket.dart): + +```yaml +MatchCard: + body: + Column: + styles: + tvOptions: + row: 0 # Required for focus styling (value overridden by bracket) + backgroundColor: 0xff303030 # Background when focused +``` + +### Focus Styling Priority (Bracket Tabs) + +Same as other widgets: + +1. `tabStyles.focusBorderColor` / `focusBorderWidth` / `focusBorderRadius` +2. Theme (`EnsembleThemeExtension.tvFocusTheme`) +3. Provider (`TVFocusProviderScope`) +4. `tabStyles.borderColor` / `borderWidth` / `borderRadius` +5. Defaults (focusBorderWidth: 2.0, focusBorderRadius: 8.0) + +For complete documentation, see: [modules/bracket/README.md](../modules/bracket/README.md) + +--- + +## Related Documentation + +- [TV_FOCUS_NAVIGATION_RULES.md](TV_FOCUS_NAVIGATION_RULES.md) - Quick reference patterns and rules for TV navigation YAML +- [TV_IMPLEMENTATION_HISTORY.md](TV_IMPLEMENTATION_HISTORY.md) - Technical implementation details and commit history +- [ENSEMBLE_FRAMEWORK_REFERENCE.md](ENSEMBLE_FRAMEWORK_REFERENCE.md) - General Ensemble framework reference +- [modules/bracket/README.md](../modules/bracket/README.md) - Bracket widget documentation with TV support + +--- + +## Appendix: Quick Reference Card + +### Minimum TV-Enabled Widget + +```yaml +Button: + label: "Click" + styles: + tvOptions: + row: 1 + order: 0 + onTap: ... +``` + +### Horizontal List Item + +```yaml +tvOptions: + row: 2 + order: ${index} + isRowEntryPoint: ${index == 0} + lockHorizontalNavigation: true + fixedFocusScroll: true + fixedFocusOffset: 48 +``` + +### Carousel Item Button + +```yaml +tvOptions: + row: 0 + order: ${slideIndex} + delegateHorizontalNavigation: true + verticalScrollPadding: 400 +``` + +### Carousel Container + +```yaml +tvOptions: + pauseAutoplayOnFocus: true + interceptHorizontalNav: true + restoreFocusOnPageChange: true +``` diff --git a/modules/ensemble/lib/ensemble_app.dart b/modules/ensemble/lib/ensemble_app.dart index dfa7c01d3..5e12cf29c 100644 --- a/modules/ensemble/lib/ensemble_app.dart +++ b/modules/ensemble/lib/ensemble_app.dart @@ -18,6 +18,7 @@ import 'package:ensemble/framework/event/change_locale_events.dart'; import 'package:ensemble/framework/storage_manager.dart'; import 'package:ensemble/framework/theme/theme_loader.dart'; import 'package:ensemble/framework/theme_manager.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; import 'package:ensemble/framework/widget/error_screen.dart'; import 'package:ensemble/framework/widget/screen.dart'; import 'package:ensemble/ios_deep_link_manager.dart'; @@ -109,6 +110,7 @@ class EnsembleApp extends StatefulWidget { this.onAppLoad, this.forcedLocale, this.child, + this.tvFocusProvider, GlobalKey? navigatorKey, ScrollController? screenScroller, }) { @@ -140,6 +142,12 @@ class EnsembleApp extends StatefulWidget { /// use this if you want the App to start out with this local final Locale? forcedLocale; + /// Optional TV focus provider from host app. + /// When provided, Ensemble widgets will use the host app's focus system + /// instead of Ensemble's built-in TVFocusWidget. This enables seamless + /// D-pad navigation between host app and Ensemble content. + final TVFocusProvider? tvFocusProvider; + @override State createState() => EnsembleAppState(); } @@ -424,6 +432,19 @@ class EnsembleAppState extends State with WidgetsBindingObserver { EnsembleThemeManager().currentTheme()?.appThemeData == null) { //backward compatibility in case apps are using the old style of App level theming that is at the root level theme = config.getAppTheme(); + + // Preserve tvFocusTheme from EnsembleThemeManager if available + // This ensures TV focus styling works even with legacy themes + final currentThemeData = EnsembleThemeManager().currentTheme()?.appThemeData; + final tvFocusTheme = currentThemeData?.extension()?.tvFocusTheme; + if (tvFocusTheme != null) { + final existingExtension = theme.extension(); + if (existingExtension != null) { + theme = theme.copyWith( + extensions: [existingExtension.copyWith(tvFocusTheme: tvFocusTheme)], + ); + } + } } else { theme = EnsembleThemeManager().currentTheme()!.appThemeData; } @@ -481,6 +502,16 @@ class EnsembleAppState extends State with WidgetsBindingObserver { // child: app, // ); // } + + // Wrap with TV focus provider if provided by host app + // This enables host app's focus system to manage Ensemble widgets + if (widget.tvFocusProvider != null) { + app = TVFocusProviderScope( + provider: widget.tvFocusProvider!, + child: app, + ); + } + return app; } diff --git a/modules/ensemble/lib/framework/device.dart b/modules/ensemble/lib/framework/device.dart index 7e7dd64d3..2623d0310 100644 --- a/modules/ensemble/lib/framework/device.dart +++ b/modules/ensemble/lib/framework/device.dart @@ -58,6 +58,9 @@ class Device "macOsInfo": () => DeviceMacOsInfo(), "windowsInfo": () => DeviceWindowsInfo(), + // TV detection + "isTV": () => isTV, + // @deprecated. backward compatibility DevicePlatform.web.name: () => DeviceWebInfo() }; @@ -71,6 +74,7 @@ class Device 'isWeb': () => platform == DevicePlatform.web, 'isMacOS': () => platform == DevicePlatform.macos, 'isWindows': () => platform == DevicePlatform.windows, + 'isTV': () => isTV, // deprecated. Should be using Action instead 'openAppSettings': (target) => openAppSettings(target), @@ -126,8 +130,31 @@ mixin DeviceInfoCapability { static MacOsDeviceInfo? macOsInfo; static WindowsDeviceInfo? windowsInfo; + // Android TV detection cache + static bool? _isTV; + DevicePlatform? get platform => _platform; + /// Returns true if the device is an Android TV + /// Checks for TV-specific system features + bool get isTV { + if (_isTV != null) return _isTV!; + + // Only Android devices can be TVs (for now) + if (kIsWeb || _platform != DevicePlatform.android || androidInfo == null) { + _isTV = false; + return false; + } + + // Check for TV system features + final systemFeatures = androidInfo!.systemFeatures; + _isTV = systemFeatures.contains('android.hardware.type.television') || + systemFeatures.contains('android.software.leanback') || + systemFeatures.contains('android.software.leanback_only'); + + return _isTV!; + } + /// initialize device info void initDeviceInfo() async { try { diff --git a/modules/ensemble/lib/framework/theme/theme_loader.dart b/modules/ensemble/lib/framework/theme/theme_loader.dart index 4362a76d7..4357013e5 100644 --- a/modules/ensemble/lib/framework/theme/theme_loader.dart +++ b/modules/ensemble/lib/framework/theme/theme_loader.dart @@ -1,6 +1,7 @@ import 'package:ensemble/framework/extensions.dart'; import 'package:ensemble/framework/theme/default_theme.dart'; import 'package:ensemble/framework/theme/theme_manager.dart'; +import 'package:ensemble/framework/tv/tv_focus_theme.dart'; import 'package:ensemble/model/text_scale.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/image.dart'; @@ -23,6 +24,7 @@ mixin ThemeLoader { YamlMap? colorOverrides, YamlMap? screenOverrides, YamlMap? widgetOverrides, + YamlMap? tokensOverrides, }) { if (appOverrides == null) { @@ -37,6 +39,9 @@ mixin ThemeLoader { if (widgetOverrides == null) { widgetOverrides = overrides?['Widgets']; } + if (tokensOverrides == null) { + tokensOverrides = overrides?['Tokens']; + } final seedColor = Utils.getColor(colorOverrides?['seed']); String _defaultFontFamily = appOverrides?['fontFamily']?? appOverrides?['textStyle']?['fontFamily'] ?? 'Inter'; TextStyle? defaultFontFamily = Utils.getFontFamily(_defaultFontFamily) ?? TextStyle(); @@ -174,6 +179,7 @@ mixin ThemeLoader { loadingScreenIndicatorColor: Utils.getColor( colorOverrides?['loadingScreenIndicatorColor']), transitions: Utils.getMap(overrides?['Transitions']), + tvFocusTheme: _parseTVFocusTheme(tokensOverrides), ) ]); } @@ -509,6 +515,30 @@ mixin ThemeLoader { ///------------ publicly available theme getters ------------- BorderRadius getInputDefaultBorderRadius(InputVariant? variant) => BorderRadius.all(Radius.circular(variant == InputVariant.box ? 8 : 0)); + + /// Parses TV focus theme configuration from theme tokens. + /// + /// Looks for TV configuration under Tokens.TV in the theme YAML: + /// ```yaml + /// Tokens: + /// TV: + /// focusBorderColor: 0xFF00AAFF + /// focusBorderWidth: 3 + /// focusBorderRadius: 8 + /// focusAnimationDuration: 150 + /// ``` + TVFocusTheme? _parseTVFocusTheme(YamlMap? tokens) { + final tvTokens = tokens?['TV']; + if (tvTokens == null) return null; + + return TVFocusTheme( + focusBorderColor: Utils.getColor(tvTokens['focusBorderColor']), + focusBorderWidth: Utils.optionalDouble(tvTokens['focusBorderWidth']), + focusBorderRadius: Utils.optionalDouble(tvTokens['focusBorderRadius']), + focusAnimationDurationMs: + Utils.optionalInt(tvTokens['focusAnimationDuration']), + ); + } } /// Configures image cache settings from App.imageCache in theme.yaml. @@ -548,26 +578,36 @@ extension CheckboxThemeDataExtension on CheckboxThemeData { /// extend Theme to add our own special color parameters class EnsembleThemeExtension extends ThemeExtension { - EnsembleThemeExtension( - {this.appTheme, - this.loadingScreenBackgroundColor, - this.loadingScreenIndicatorColor, - this.transitions}); + EnsembleThemeExtension({ + this.appTheme, + this.loadingScreenBackgroundColor, + this.loadingScreenIndicatorColor, + this.transitions, + this.tvFocusTheme, + }); final AppTheme? appTheme; final Color? loadingScreenBackgroundColor; final Color? loadingScreenIndicatorColor; // should deprecate this final Map? transitions; + /// TV focus styling configuration parsed from theme.yaml. + /// Used as the highest priority source for TV focus indicator styling. + final TVFocusTheme? tvFocusTheme; + @override - ThemeExtension copyWith( - {Color? loadingScreenBackgroundColor, - Color? loadingScreenIndicatorColor}) { + ThemeExtension copyWith({ + Color? loadingScreenBackgroundColor, + Color? loadingScreenIndicatorColor, + TVFocusTheme? tvFocusTheme, + }) { return EnsembleThemeExtension( - loadingScreenBackgroundColor: - loadingScreenBackgroundColor ?? this.loadingScreenBackgroundColor, - loadingScreenIndicatorColor: - loadingScreenIndicatorColor ?? this.loadingScreenIndicatorColor); + loadingScreenBackgroundColor: + loadingScreenBackgroundColor ?? this.loadingScreenBackgroundColor, + loadingScreenIndicatorColor: + loadingScreenIndicatorColor ?? this.loadingScreenIndicatorColor, + tvFocusTheme: tvFocusTheme ?? this.tvFocusTheme, + ); } @override @@ -581,6 +621,8 @@ class EnsembleThemeExtension extends ThemeExtension { loadingScreenBackgroundColor, other.loadingScreenBackgroundColor, t), loadingScreenIndicatorColor: Color.lerp( loadingScreenIndicatorColor, other.loadingScreenIndicatorColor, t), + // TV focus theme doesn't need lerping - use target value + tvFocusTheme: t < 0.5 ? tvFocusTheme : other.tvFocusTheme, ); } } diff --git a/modules/ensemble/lib/framework/theme_manager.dart b/modules/ensemble/lib/framework/theme_manager.dart index 2255c47b9..cc713ff3c 100644 --- a/modules/ensemble/lib/framework/theme_manager.dart +++ b/modules/ensemble/lib/framework/theme_manager.dart @@ -349,9 +349,17 @@ class EnsembleTheme { initialized = true; return this; } + /// Initialize app-level ThemeData with tokens and styles. + /// Pass tokensOverrides so TV-related tokens (e.g., Tokens.TV.focusColor) + /// are parsed and included in the theme's EnsembleThemeExtension. void initAppThemeData() { YamlMap? yamlStyles = styles != null ? YamlMap.wrap(styles) : null; - appThemeData = ThemeManager().getAppTheme(yamlStyles,widgetOverrides: yamlStyles); + YamlMap? yamlTokens = tokens.isNotEmpty ? YamlMap.wrap(tokens) : null; + appThemeData = ThemeManager().getAppTheme( + yamlStyles, + widgetOverrides: yamlStyles, + tokensOverrides: yamlTokens, + ); } Map? getIDStyles(String? id) { return (id == null) ? {} : styles['#$id']; diff --git a/modules/ensemble/lib/framework/tv/tv_focus_context.dart b/modules/ensemble/lib/framework/tv/tv_focus_context.dart new file mode 100644 index 000000000..c3705d17e --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_context.dart @@ -0,0 +1,38 @@ +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:flutter/widgets.dart'; + +/// Inherited TV focus defaults for a subtree. +/// +/// A parent widget can provide a [focusGroup] and edge targets without becoming +/// focusable itself. Descendant focusable widgets inherit these values unless +/// they define their own tvOptions values. +class TVFocusContext extends InheritedWidget { + const TVFocusContext({ + super.key, + required super.child, + this.focusGroup, + this.rightEdge, + this.leftEdge, + this.topEdge, + this.bottomEdge, + }); + + final String? focusGroup; + final TVFocusEdgeTargetComposite? rightEdge; + final TVFocusEdgeTargetComposite? leftEdge; + final TVFocusEdgeTargetComposite? topEdge; + final TVFocusEdgeTargetComposite? bottomEdge; + + static TVFocusContext? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(TVFocusContext oldWidget) { + return focusGroup != oldWidget.focusGroup || + rightEdge != oldWidget.rightEdge || + leftEdge != oldWidget.leftEdge || + topEdge != oldWidget.topEdge || + bottomEdge != oldWidget.bottomEdge; + } +} diff --git a/modules/ensemble/lib/framework/tv/tv_focus_navigation.dart b/modules/ensemble/lib/framework/tv/tv_focus_navigation.dart new file mode 100644 index 000000000..0a219d913 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_navigation.dart @@ -0,0 +1,96 @@ +import 'package:ensemble/framework/tv/tv_focus_context.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:flutter/widgets.dart'; + +String? resolveTVFocusGroup( + BuildContext context, + TVOptionsComposite tvOptions, +) { + return tvOptions.focusGroup ?? TVFocusContext.maybeOf(context)?.focusGroup; +} + +TVFocusEdgeTargetComposite? resolveTVFocusEdgeTarget( + BuildContext context, + TVOptionsComposite tvOptions, + TVFocusDirection direction, +) { + final inheritedContext = TVFocusContext.maybeOf(context); + switch (direction) { + case TVFocusDirection.right: + return tvOptions.edges?.right ?? inheritedContext?.rightEdge; + case TVFocusDirection.left: + return tvOptions.edges?.left ?? inheritedContext?.leftEdge; + case TVFocusDirection.top: + return tvOptions.edges?.top ?? inheritedContext?.topEdge; + case TVFocusDirection.bottom: + return tvOptions.edges?.bottom ?? inheritedContext?.bottomEdge; + } +} + +VoidCallback? buildTVEdgeNavigationCallback({ + required BuildContext context, + required TVFocusProvider? provider, + required TVFocusDirection direction, + required TVFocusEdgeTargetComposite? target, + required double currentRow, + required double currentOrder, +}) { + if (target == null) { + return null; + } + if (target.targetRow == null && target.targetFocusGroup == null) { + return null; + } + + final rowOffset = provider?.rowOffset ?? 0; + final orderOffset = provider?.orderOffset ?? 0; + final effectiveTargetRow = + target.targetRow != null ? target.targetRow! + rowOffset : null; + final effectiveTargetOrder = + target.targetOrder != null ? target.targetOrder! + orderOffset : null; + + if (provider != null) { + final p = provider; + return () => p.requestFocusByEdge( + context, + direction: direction, + targetFocusGroup: target.targetFocusGroup, + targetRow: effectiveTargetRow, + targetOrder: effectiveTargetOrder, + currentRow: currentRow, + currentOrder: currentOrder, + ); + } + + return () => requestFocusByEdge( + context, + direction: direction, + targetFocusGroup: target.targetFocusGroup, + targetRow: effectiveTargetRow, + targetOrder: effectiveTargetOrder, + currentRow: currentRow, + currentOrder: currentOrder, + ); +} + +Widget wrapWithTVFocusContext({ + required BuildContext context, + required Widget child, + required TVOptionsComposite? tvOptions, +}) { + if (tvOptions?.focusGroup == null && tvOptions?.edges == null) { + return child; + } + + final inheritedContext = TVFocusContext.maybeOf(context); + return TVFocusContext( + focusGroup: tvOptions?.focusGroup ?? inheritedContext?.focusGroup, + rightEdge: tvOptions?.edges?.right ?? inheritedContext?.rightEdge, + leftEdge: tvOptions?.edges?.left ?? inheritedContext?.leftEdge, + topEdge: tvOptions?.edges?.top ?? inheritedContext?.topEdge, + bottomEdge: tvOptions?.edges?.bottom ?? inheritedContext?.bottomEdge, + child: child, + ); +} diff --git a/modules/ensemble/lib/framework/tv/tv_focus_order.dart b/modules/ensemble/lib/framework/tv/tv_focus_order.dart new file mode 100644 index 000000000..aa20ada06 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_order.dart @@ -0,0 +1,653 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import 'tv_focus_registry.dart'; + +// ============================================================================= +// TV Focus Order - 2D Grid Coordinate System +// ============================================================================= + +enum TVFocusDirection { + left, + right, + top, + bottom, +} + +/// 2D coordinate for TV D-pad navigation. Maps to flutter_pca's PageFocusOrder. +/// +/// ## Coordinate System +/// - [row]: Vertical position (0, 1, 2...). Items in same row navigate with LEFT/RIGHT. +/// - [order]: Horizontal position within row. Lower = more left. +/// +/// ## Constructors +/// - `TVFocusOrder(row, order)` - Simple positioning, all flags default false. +/// - `TVFocusOrder.withOptions(row, ...)` - Full control over navigation flags. +/// +/// ## Example +/// ```dart +/// TVFocusOrder(1, 2) // Row 1, position 2 +/// TVFocusOrder.withOptions(0, order: 3, isRowEntryPoint: true) // Tab bar entry +/// ``` +class TVFocusOrder extends FocusOrder { + /// Creates a focus order with basic positioning. + /// + /// Use this for simple cases where you only need row/order coordinates. + /// For setting [isRowEntryPoint] or navigation options, use [TVFocusOrder.withOptions]. + const TVFocusOrder( + this.row, [ + this.order = 0, + ]) : isRowEntryPoint = false, + lockHorizontalNavigation = false, + delegateHorizontalNavigation = false, + focusGroup = null; + + /// Creates a focus order with full control over all options. + /// + /// Use this when you need to set navigation behavior options like + /// [isRowEntryPoint], [lockHorizontalNavigation], or [delegateHorizontalNavigation]. + const TVFocusOrder.withOptions( + this.row, { + this.order = 0, + this.isRowEntryPoint = false, + this.lockHorizontalNavigation = false, + this.delegateHorizontalNavigation = false, + this.focusGroup, + }); + + final double row; + final double order; + + /// If true, this item is the preferred entry point when navigating to this row. + /// Used by TabBar to focus the selected tab when entering the tab row. + final bool isRowEntryPoint; + + /// If true, prevents horizontal navigation from escaping this row at boundaries. + /// When at the first item, LEFT won't propagate; when at the last item, RIGHT won't propagate. + final bool lockHorizontalNavigation; + + /// If true, horizontal navigation (LEFT/RIGHT) is delegated to the parent FocusScope. + /// Use this for items inside carousels where horizontal keys should switch slides. + final bool delegateHorizontalNavigation; + + /// Optional focus group. + /// + /// When set, focus movement only considers widgets that share the same group. + /// This is useful for keeping two nearby UI regions separate while still allowing + /// explicit edge callbacks to move focus between them. + final String? focusGroup; + + /// Composite value for sorting: row * 10000 + order + /// This ensures items are sorted by row first, then by order within row + double get value => row * 10000 + order; + + @override + int doCompare(TVFocusOrder other) => value.compareTo(other.value); + + /// Create a new TVFocusOrder offset from this one + TVFocusOrder offset({double rowOffset = 0, double orderOffset = 0}) { + return TVFocusOrder.withOptions( + row + rowOffset, + order: order + orderOffset, + isRowEntryPoint: isRowEntryPoint, + lockHorizontalNavigation: lockHorizontalNavigation, + delegateHorizontalNavigation: delegateHorizontalNavigation, + focusGroup: focusGroup, + ); + } + + /// Request focus on the widget with this order coordinate. + /// Scoped to current route to prevent stealing focus from other screens. + void requestFocus(BuildContext context) { + final route = ModalRoute.of(context); + final root = FocusManager.instance.rootScope; + final candidatesByOrder = {}; + + for (final target in TVFocusRegistry.targets( + route: route, + )) { + final gridFocusOrder = target.focusOrder as TVFocusOrder; + TVFocusOrderNode.addPreferredCandidate( + candidatesByOrder, + TVFocusOrderNode( + target.focusNode, + gridFocusOrder, + isRegisteredTarget: true, + ), + ); + } + + for (final focusNode in root.descendants) { + if (focusNode.context == null) continue; + if (!focusNode.canRequestFocus) continue; + if (!_isInRoute(focusNode.context!, route)) continue; + + final focusTraversalOrder = focusNode.context + ?.findAncestorWidgetOfExactType(); + if (focusTraversalOrder?.order is TVFocusOrder) { + final gridFocusOrder = focusTraversalOrder!.order as TVFocusOrder; + TVFocusOrderNode.addPreferredCandidate( + candidatesByOrder, + TVFocusOrderNode(focusNode, gridFocusOrder), + ); + } + } + + final targetNode = candidatesByOrder.values.firstWhereOrNull( + (node) => + node.order.value == value && node.order.focusGroup == focusGroup, + ); + targetNode?.focus.requestFocus(); + } + + /// Request focus on a specific row/order. + /// Scoped to current route to prevent stealing focus from other screens. + /// If [order] is omitted, the row's entry point is used when available. + void requestFocusAt(BuildContext context, double row, + [double? order, String? focusGroup]) { + final route = ModalRoute.of(context); + final root = FocusManager.instance.rootScope; + final candidatesByOrder = {}; + final rowNodesByOrder = {}; + + for (final target in TVFocusRegistry.targets( + route: route, + focusGroup: focusGroup, + )) { + final gridFocusOrder = target.focusOrder as TVFocusOrder; + final node = TVFocusOrderNode( + target.focusNode, + gridFocusOrder, + isRegisteredTarget: true, + ); + TVFocusOrderNode.addPreferredCandidate(candidatesByOrder, node); + if (gridFocusOrder.row != row) continue; + TVFocusOrderNode.addPreferredCandidate(rowNodesByOrder, node); + } + + for (final focusNode in root.descendants) { + if (focusNode.context == null) continue; + if (!focusNode.canRequestFocus) continue; + if (!_isInRoute(focusNode.context!, route)) continue; + + final focusTraversalOrder = focusNode.context + ?.findAncestorWidgetOfExactType(); + if (focusTraversalOrder?.order is TVFocusOrder) { + final gridFocusOrder = focusTraversalOrder!.order as TVFocusOrder; + if (focusGroup != null && gridFocusOrder.focusGroup != focusGroup) { + continue; + } + final node = TVFocusOrderNode(focusNode, gridFocusOrder); + TVFocusOrderNode.addPreferredCandidate(candidatesByOrder, node); + if (gridFocusOrder.row != row) continue; + TVFocusOrderNode.addPreferredCandidate(rowNodesByOrder, node); + } + } + + final candidates = candidatesByOrder.values.toList(); + final rowNodes = rowNodesByOrder.values.toList(); + + if (order != null) { + TVFocusOrderNode? exactNode; + for (final node in rowNodes) { + if (node.order.order == order) { + exactNode = node; + break; + } + } + if (exactNode != null) { + exactNode.focus.requestFocus(); + return; + } + } + + if (order != null && rowNodes.isNotEmpty) { + _requestBestNodeInRow(rowNodes, order); + return; + } + + if (rowNodes.isEmpty) { + final nearestRow = _findNearestRow(candidates, row); + if (nearestRow == null) { + return; + } + _requestBestNodeInRow(nearestRow, order); + return; + } + + _requestBestNodeInRow(rowNodes, order); + } + + /// Request focus from an edge into another focus group. + /// + /// Unlike [requestFocusAt], [targetRow] and [targetOrder] are optional hints. + /// When they are omitted, the target is selected deterministically from the + /// requested group based on the direction of travel. + void requestFocusByEdge( + BuildContext context, { + required TVFocusDirection direction, + String? targetFocusGroup, + double? targetRow, + double? targetOrder, + double? currentRow, + double? currentOrder, + }) { + final route = ModalRoute.of(context); + final root = FocusManager.instance.rootScope; + final candidatesByOrder = {}; + + for (final target in TVFocusRegistry.targets( + route: route, + focusGroup: targetFocusGroup, + )) { + final gridFocusOrder = target.focusOrder as TVFocusOrder; + TVFocusOrderNode.addPreferredCandidate( + candidatesByOrder, + TVFocusOrderNode( + target.focusNode, + gridFocusOrder, + isRegisteredTarget: true, + ), + ); + } + + for (final focusNode in root.descendants) { + if (focusNode.context == null) continue; + if (!focusNode.canRequestFocus) continue; + if (!_isInRoute(focusNode.context!, route)) continue; + + final focusTraversalOrder = focusNode.context + ?.findAncestorWidgetOfExactType(); + if (focusTraversalOrder?.order is TVFocusOrder) { + final gridFocusOrder = focusTraversalOrder!.order as TVFocusOrder; + if (targetFocusGroup != null && + gridFocusOrder.focusGroup != targetFocusGroup) { + continue; + } + TVFocusOrderNode.addPreferredCandidate( + candidatesByOrder, + TVFocusOrderNode(focusNode, gridFocusOrder), + ); + } + } + + final candidates = candidatesByOrder.values.toList(); + if (candidates.isEmpty) { + return; + } + + final grid = TVFocusOrderNode.buildGrid(candidates); + if (grid.isEmpty) { + return; + } + + final targetRowNodes = _selectRowForEdge( + grid, + direction: direction, + targetRow: targetRow, + currentRow: currentRow, + ); + if (targetRowNodes == null || targetRowNodes.isEmpty) { + return; + } + + final targetNode = _selectNodeForEdge( + targetRowNodes, + direction: direction, + targetOrder: targetOrder, + currentOrder: currentOrder, + ); + targetNode?.focus.requestFocus(); + } + + static List? _selectRowForEdge( + List> grid, { + required TVFocusDirection direction, + double? targetRow, + double? currentRow, + }) { + if (targetRow != null) { + List? nearest; + var nearestDiff = double.infinity; + for (final rowNodes in grid) { + final diff = (rowNodes.first.order.row - targetRow).abs(); + if (diff < nearestDiff) { + nearest = rowNodes; + nearestDiff = diff; + } + } + return nearest; + } + + switch (direction) { + case TVFocusDirection.right: + case TVFocusDirection.left: + if (currentRow == null) { + return grid.first; + } + return _findNearestRow( + grid.expand((row) => row).toList(), + currentRow, + ); + case TVFocusDirection.bottom: + return grid.first; + case TVFocusDirection.top: + return grid.last; + } + } + + static TVFocusOrderNode? _selectNodeForEdge( + List rowNodes, { + required TVFocusDirection direction, + double? targetOrder, + double? currentOrder, + }) { + if (rowNodes.isEmpty) { + return null; + } + + if (targetOrder != null) { + return _nearestNodeByOrder(rowNodes, targetOrder); + } + + switch (direction) { + case TVFocusDirection.right: + return rowNodes.first; + case TVFocusDirection.left: + return rowNodes.last; + case TVFocusDirection.top: + case TVFocusDirection.bottom: + for (final node in rowNodes) { + if (node.order.isRowEntryPoint) { + return node; + } + } + if (currentOrder != null) { + return _nearestNodeByOrder(rowNodes, currentOrder); + } + return rowNodes.first; + } + } + + static TVFocusOrderNode? _nearestNodeByOrder( + List rowNodes, + double targetOrder, + ) { + TVFocusOrderNode? nearest; + var nearestDiff = double.infinity; + for (final node in rowNodes) { + final diff = (node.order.order - targetOrder).abs(); + if (diff < nearestDiff) { + nearest = node; + nearestDiff = diff; + } + } + return nearest; + } + + static List? _findNearestRow( + List candidates, + double targetRow, + ) { + if (candidates.isEmpty) { + return null; + } + + final grid = TVFocusOrderNode.buildGrid(candidates); + if (grid.isEmpty) { + return null; + } + + List? nearest; + var nearestDiff = double.infinity; + for (final rowNodes in grid) { + final diff = (rowNodes.first.order.row - targetRow).abs(); + if (diff < nearestDiff) { + nearest = rowNodes; + nearestDiff = diff; + } + } + return nearest; + } + + static void _requestBestNodeInRow( + Iterable rowNodes, [ + double? targetOrder, + ]) { + final nodes = rowNodes.toList(); + if (nodes.isEmpty) { + return; + } + + if (targetOrder != null) { + TVFocusOrderNode? nearest; + var nearestDiff = double.infinity; + for (final node in nodes) { + final diff = (node.order.order - targetOrder).abs(); + if (diff < nearestDiff) { + nearest = node; + nearestDiff = diff; + } + } + nearest?.focus.requestFocus(); + return; + } + + for (final node in rowNodes) { + if (node.order.isRowEntryPoint) { + node.focus.requestFocus(); + return; + } + } + + nodes.first.focus.requestFocus(); + } + + static bool _isInRoute(BuildContext context, ModalRoute? route) { + if (route == null) { + return true; + } + return ModalRoute.of(context) == route; + } + + static bool isInRoute(BuildContext context, ModalRoute? route) { + return _isInRoute(context, route); + } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'TVFocusOrder(row: $row, order: $order, focusGroup: $focusGroup)'; + } +} + +/// Request focus for a row/order pair inside the current FocusTraversalGroup. +/// +/// This is a convenience wrapper around [TVFocusOrder.requestFocusAt] so +/// callers can jump to a target coordinate without constructing a temporary +/// order object at the call site. +void requestFocusAt(BuildContext context, double row, + [double? order, String? focusGroup]) { + const TVFocusOrder(0).requestFocusAt(context, row, order, focusGroup); +} + +void requestFocusByEdge( + BuildContext context, { + required TVFocusDirection direction, + String? targetFocusGroup, + double? targetRow, + double? targetOrder, + double? currentRow, + double? currentOrder, +}) { + const TVFocusOrder(0).requestFocusByEdge( + context, + direction: direction, + targetFocusGroup: targetFocusGroup, + targetRow: targetRow, + targetOrder: targetOrder, + currentRow: currentRow, + currentOrder: currentOrder, + ); +} + +// ============================================================================= +// Grid Building Utilities +// ============================================================================= + +/// Internal wrapper pairing a FocusNode with its TVFocusOrder coordinate. +class TVFocusOrderNode { + final FocusNode focus; + final TVFocusOrder order; + final bool isRegisteredTarget; + + const TVFocusOrderNode( + this.focus, + this.order, { + this.isRegisteredTarget = false, + }); + + @override + String toString() => 'TVFocusOrderNode(${order.value}, ${focus.hashCode})'; + + @override + bool operator ==(Object other) => + other is TVFocusOrderNode && + order.value == other.order.value && + order.focusGroup == other.order.focusGroup; + + @override + int get hashCode => Object.hash(order.value, order.focusGroup); + + static void addPreferredCandidate( + Map candidates, + TVFocusOrderNode candidate, + ) { + final existing = candidates[candidate]; + if (existing == null || isBetterCandidateNode(candidate, existing)) { + candidates[candidate] = candidate; + } + } + + static bool isBetterCandidateNode( + TVFocusOrderNode candidate, + TVFocusOrderNode existing, + ) { + if (candidate.focus.hasPrimaryFocus != existing.focus.hasPrimaryFocus) { + return candidate.focus.hasPrimaryFocus; + } + if (candidate.focus.hasFocus != existing.focus.hasFocus) { + return candidate.focus.hasFocus; + } + if (candidate.isRegisteredTarget != existing.isRegisteredTarget) { + return candidate.isRegisteredTarget; + } + return _focusNodeDepth(candidate.focus) > _focusNodeDepth(existing.focus); + } + + static bool isBetterFocusCandidate(FocusNode candidate, FocusNode existing) { + if (candidate.hasPrimaryFocus != existing.hasPrimaryFocus) { + return candidate.hasPrimaryFocus; + } + if (candidate.hasFocus != existing.hasFocus) { + return candidate.hasFocus; + } + + return _focusNodeDepth(candidate) > _focusNodeDepth(existing); + } + + static int _focusNodeDepth(FocusNode focusNode) { + var depth = 0; + focusNode.context?.visitAncestorElements((_) { + depth++; + return true; + }); + return depth; + } + + /// Build a 2D grid from an iterable of focus order nodes. + /// Items are grouped by row and sorted by order within each row. + /// + /// Example result: + /// ``` + /// [ + /// [Node(0,0), Node(0,1), Node(0,2)], // Row 0 + /// [Node(1,0), Node(1,1)], // Row 1 + /// [Node(2,0), Node(2,1), Node(2,2)], // Row 2 + /// ] + /// ``` + static List> buildGrid( + Iterable iterable, + ) { + // Sort all items by their composite value (row * 10000 + order) + final sorted = iterable.sorted( + (a, b) => a.order.value.compareTo(b.order.value), + ); + + // Group items by row + final grid = >[]; + List? currentRow; + + for (final element in sorted) { + // Start a new row if this element's row differs from current + if (currentRow == null || + currentRow.first.order.row != element.order.row) { + currentRow = []; + grid.add(currentRow); + } + currentRow.add(element); + } + + return grid; + } +} + +// ============================================================================= +// Focus Traversal Policy & Scope +// ============================================================================= + +/// Traversal policy that prevents UP navigation from escaping the Ensemble grid. +class TVFocusOrderTraversalPolicy extends ReadingOrderTraversalPolicy { + /// When true, UP at row 0 is blocked (focus stays in Ensemble content). + final bool preventOutOfScopeTopTraversal; + + TVFocusOrderTraversalPolicy({ + this.preventOutOfScopeTopTraversal = true, + }); +} + +/// Focus scope with edge handlers for scrollbar navigation and focus locking. +/// +/// Use cases: +/// - Scrollbar navigation: onRightEdge moves focus from ListView to scrollbar +/// - Modal dialogs: lockScope prevents focus from escaping +/// +/// WARNING: Extends FocusScope directly (inheritance anti-pattern). +/// Data is accessed via findAncestorWidgetOfExactType in TVFocusWidget. +/// Future refactor: Use composition with InheritedWidget for data. +class TVFocusScope extends FocusScope { + /// When true, focus cannot leave this scope (modal behavior). + final bool lockScope; + + /// Called when RIGHT pressed at rightmost edge. Use for right-side scrollbar. + final VoidCallback? onRightEdge; + + /// Called when LEFT pressed at leftmost edge. Use for left-side scrollbar. + final VoidCallback? onLeftEdge; + + /// Called when UP pressed at top edge. + final VoidCallback? onTopEdge; + + /// Called when DOWN pressed at bottom edge. + final VoidCallback? onBottomEdge; + + const TVFocusScope({ + super.key, + required this.lockScope, + required super.child, + super.debugLabel, + this.onRightEdge, + this.onLeftEdge, + this.onTopEdge, + this.onBottomEdge, + }); +} diff --git a/modules/ensemble/lib/framework/tv/tv_focus_provider.dart b/modules/ensemble/lib/framework/tv/tv_focus_provider.dart new file mode 100644 index 000000000..f954cd87c --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_provider.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'tv_focus_order.dart'; + +/// Abstract interface for TV focus navigation systems. +/// +/// This allows Ensemble to integrate with a host app's focus system +/// (e.g., flutter_pca's PageFocusWidget) instead of using its own. +/// +/// When a host app provides a [TVFocusProvider], Ensemble widgets will use +/// the host's focus system, enabling seamless D-pad navigation between +/// host app content and Ensemble content. +/// +/// ## Why This Exists +/// +/// When Ensemble is embedded in a host app (like flutter_pca), both apps have +/// their own TV focus systems. Without integration, they operate as separate +/// grids with no way to navigate between them. +/// +/// By providing a [TVFocusProvider], the host app can: +/// 1. Register Ensemble widgets in its own focus grid +/// 2. Enable seamless UP/DOWN/LEFT/RIGHT navigation across the entire app +/// 3. Maintain a single source of truth for focus state +/// +/// ## Usage in Host App +/// +/// ```dart +/// // 1. Create your provider implementation +/// class MyFocusProvider implements TVFocusProvider { +/// @override +/// Widget wrapFocusable({ +/// required double row, +/// required double order, +/// required Widget child, +/// bool isRowEntryPoint = false, +/// bool lockHorizontalNavigation = false, +/// String? focusGroup, +/// KeyEventResult Function(FocusNode)? onBackPressed, +/// }) { +/// return PageFocusWidget( +/// focusOrder: PageFocusOrder( +/// row, order, +/// isRowEntryPoint: isRowEntryPoint, +/// lockHorizontalNavigation: lockHorizontalNavigation, +/// ), +/// onBackPressed: onBackPressed, +/// child: child, +/// ); +/// } +/// } +/// +/// // 2. Provide it to Ensemble +/// EnsembleWrapper( +/// tvFocusProvider: MyFocusProvider(), +/// child: EnsembleScreen(...), +/// ) +/// ``` +abstract class TVFocusProvider { + /// Creates a focusable widget wrapper with the given coordinates. + /// + /// Parameters: + /// - [row]: Vertical position in the focus grid (0, 1, 2, ...) + /// - [order]: Horizontal position within the row (0, 1, 2, ...) + /// - [isRowEntryPoint]: If true, this is the preferred focus target when + /// navigating INTO this row from another row. Useful for tabs where + /// the selected tab should receive focus. + /// - [lockHorizontalNavigation]: If true, prevents focus from escaping + /// this row at horizontal boundaries (left/right edges). Useful for + /// horizontal lanes where focus should stay locked within the row. + /// - [delegateHorizontalNavigation]: If true, horizontal navigation events + /// (LEFT/RIGHT) are delegated to the parent FocusScope instead of being + /// handled locally. Useful for items inside carousels where horizontal + /// keys should switch slides. + /// - [focusGroup]: Optional logical group. When set, D-pad traversal only + /// considers focusables in the same group unless an edge handler moves + /// focus explicitly. + /// - [child]: The widget to make focusable. Should contain an InkWell + /// or similar focusable widget. + /// - [onBackPressed]: Optional callback for Android TV back button. + /// - [onRightEdge]: Optional callback when at right edge of grid and RIGHT pressed. + /// Used for scrollbar navigation from ListView content. + /// - [onLeftEdge]: Optional callback when at left edge of grid and LEFT pressed. + /// Used for left-positioned scrollbar navigation. + /// - [onTopEdge]: Optional callback when at top edge and UP pressed. + /// - [onBottomEdge]: Optional callback when at bottom edge and DOWN pressed. + /// + /// The returned widget should: + /// - Handle D-pad key events (UP/DOWN/LEFT/RIGHT) + /// - Participate in the host app's focus traversal grid + /// - Support auto-scrolling to keep focused item visible + /// - Call edge handlers when navigation reaches grid boundaries + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + String? focusGroup, + FocusNode? primaryFocusNode, + KeyEventResult Function(FocusNode node)? onBackPressed, + VoidCallback? onRightEdge, + VoidCallback? onLeftEdge, + VoidCallback? onTopEdge, + VoidCallback? onBottomEdge, + }); + + /// Optional: Row offset for Ensemble content. + /// + /// Ensemble's YAML-defined `tvRow` values are relative (0, 1, 2...). + /// This offset is added to create absolute positions in the host app's grid. + /// + /// Example: If host app's tab bar is at row 0, set rowOffset to 1 + /// so Ensemble content starts at row 1. + /// + /// Default: 0 (no offset) + double get rowOffset => 0; + + /// Optional: Order (horizontal) offset for Ensemble content. + /// + /// Ensemble's YAML-defined `tvOrder` values are relative (0, 1, 2...). + /// This offset is added to create absolute positions in the host app's grid. + /// + /// Example: If Sports tab is at order 5, set orderOffset to 5 + /// so navigating UP from Ensemble naturally lands on the Sports tab. + /// + /// Default: 0 (no offset) + double get orderOffset => 0; + + // ───────────────────────────────────────────────────────────────────────── + // TV Focus Styling (optional overrides from host app) + // Priority: Ensemble Theme > Provider > Default fallback + // ───────────────────────────────────────────────────────────────────────── + + /// Optional: Focus indicator border color. + /// + /// When provided, overrides Ensemble's default focus border color. + /// Theme configuration takes priority over this value. + /// + /// Default: null (use theme or fallback to Color(0xFF00E676)) + Color? get focusBorderColor => null; + + /// Optional: Focus indicator border width. + /// + /// When provided, overrides Ensemble's default border width. + /// Theme configuration takes priority over this value. + /// + /// Default: null (use theme or fallback to 3.0) + double? get focusBorderWidth => null; + + /// Optional: Focus indicator border radius. + /// + /// When provided, overrides Ensemble's default border radius. + /// Theme configuration takes priority over this value. + /// + /// Default: null (use theme or fallback to 8.0) + double? get focusBorderRadius => null; + + /// Optional: Focus animation duration in milliseconds. + /// + /// When provided, overrides Ensemble's default animation duration. + /// Theme configuration takes priority over this value. + /// + /// Default: null (use theme or fallback to 150ms) + int? get focusAnimationDurationMs => null; + + /// Whether the host app handles horizontal scrolling for focused items. + /// + /// When true, Ensemble will skip its horizontal scroll logic and let + /// the host app manage scrolling. This prevents double-scrolling when + /// both systems try to scroll the same content. + /// + /// Default: false (Ensemble handles horizontal scrolling) + bool get handlesHorizontalScroll => false; + + /// Request focus on a widget at the given row/order. + /// + /// Used by edge callbacks to navigate across focus groups. + /// The host app should find its own [PageFocusOrder] widgets at these + /// coordinates and request focus on them. + /// + /// Default implementation uses the framework's [TVFocusOrder.requestFocusAt]. + /// Override if host app uses a different order type (e.g., [PageFocusOrder]). + void requestFocusAt(BuildContext context, double row, + [double? order, String? focusGroup]) { + const TVFocusOrder(0).requestFocusAt(context, row, order, focusGroup); + } + + /// Request focus from an edge into a target focus group. + /// + /// [targetRow] and [targetOrder] are optional hints. When omitted, the + /// implementation should pick a deterministic target inside + /// [targetFocusGroup] based on [direction]. + void requestFocusByEdge( + BuildContext context, { + required TVFocusDirection direction, + String? targetFocusGroup, + double? targetRow, + double? targetOrder, + double? currentRow, + double? currentOrder, + }) { + const TVFocusOrder(0).requestFocusByEdge( + context, + direction: direction, + targetFocusGroup: targetFocusGroup, + targetRow: targetRow, + targetOrder: targetOrder, + currentRow: currentRow, + currentOrder: currentOrder, + ); + } + + /// Disposes any resources held by this provider. + void dispose() {} +} + +/// InheritedWidget that provides [TVFocusProvider] to the widget tree. +/// +/// Ensemble widgets look up this provider to determine how to handle TV focus. +/// If not found, they use Ensemble's built-in [TVFocusWidget]. +class TVFocusProviderScope extends InheritedWidget { + const TVFocusProviderScope({ + super.key, + required this.provider, + required super.child, + }); + + final TVFocusProvider provider; + + /// Get the provider from the widget tree, or null if not provided. + static TVFocusProvider? of(BuildContext context) { + final scope = + context.dependOnInheritedWidgetOfExactType(); + return scope?.provider; + } + + /// Get provider without registering dependency (for one-time lookups). + static TVFocusProvider? maybeOf(BuildContext context) { + final scope = context.getInheritedWidgetOfExactType(); + return scope?.provider; + } + + @override + bool updateShouldNotify(TVFocusProviderScope oldWidget) { + return provider != oldWidget.provider; + } +} diff --git a/modules/ensemble/lib/framework/tv/tv_focus_registry.dart b/modules/ensemble/lib/framework/tv/tv_focus_registry.dart new file mode 100644 index 000000000..8f2886b2a --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_registry.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; + +/// Explicit focus target for a TV focus coordinate. +/// +/// This lets widgets that own the real requestable [FocusNode] register it +/// directly instead of forcing navigation code to infer the right node from the +/// focus tree. +class TVFocusTarget { + const TVFocusTarget({ + required this.focusNode, + required this.focusOrder, + required this.row, + required this.order, + required this.context, + this.focusGroup, + this.isRowEntryPoint = false, + this.lockHorizontalNavigation = false, + this.delegateHorizontalNavigation = false, + }); + + final FocusNode focusNode; + final FocusOrder focusOrder; + final double row; + final double order; + final BuildContext context; + final String? focusGroup; + final bool isRowEntryPoint; + final bool lockHorizontalNavigation; + final bool delegateHorizontalNavigation; + + BuildContext? get effectiveContext => focusNode.context ?? context; + + bool get isRequestable => + focusNode.context != null && focusNode.canRequestFocus; + + bool isInRoute(ModalRoute? route) { + if (route == null) { + return true; + } + final targetContext = effectiveContext; + return targetContext != null && ModalRoute.of(targetContext) == route; + } + + bool isInTraversalGroup(FocusTraversalGroup? traversalGroup) { + if (traversalGroup == null) { + return true; + } + final targetContext = effectiveContext; + return targetContext + ?.findAncestorWidgetOfExactType() == + traversalGroup; + } +} + +/// Route-aware registry of explicit TV focus targets. +class TVFocusRegistry { + TVFocusRegistry._(); + + static final Map _targets = + {}; + + static void register(TVFocusTarget target) { + _targets[target.focusNode] = target; + } + + static void unregister(FocusNode focusNode) { + _targets.remove(focusNode); + } + + static Iterable targets({ + ModalRoute? route, + FocusTraversalGroup? traversalGroup, + String? focusGroup, + }) { + return _targets.values.where((target) { + if (target.focusOrder is! T) { + return false; + } + if (!target.isRequestable) { + return false; + } + if (!target.isInRoute(route)) { + return false; + } + if (!target.isInTraversalGroup(traversalGroup)) { + return false; + } + if (focusGroup != null && target.focusGroup != focusGroup) { + return false; + } + return true; + }); + } +} + +/// Registers a focus target while its widget subtree is mounted. +class TVFocusTargetRegistrar extends StatefulWidget { + const TVFocusTargetRegistrar({ + super.key, + required this.focusNode, + required this.focusOrder, + required this.row, + required this.order, + required this.child, + this.focusGroup, + this.isRowEntryPoint = false, + this.lockHorizontalNavigation = false, + this.delegateHorizontalNavigation = false, + }); + + final FocusNode focusNode; + final FocusOrder focusOrder; + final double row; + final double order; + final String? focusGroup; + final bool isRowEntryPoint; + final bool lockHorizontalNavigation; + final bool delegateHorizontalNavigation; + final Widget child; + + @override + State createState() => _TVFocusTargetRegistrarState(); +} + +class _TVFocusTargetRegistrarState extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _register(); + } + + @override + void didUpdateWidget(TVFocusTargetRegistrar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + TVFocusRegistry.unregister(oldWidget.focusNode); + } + _register(); + } + + @override + void dispose() { + TVFocusRegistry.unregister(widget.focusNode); + super.dispose(); + } + + void _register() { + TVFocusRegistry.register( + TVFocusTarget( + focusNode: widget.focusNode, + focusOrder: widget.focusOrder, + row: widget.row, + order: widget.order, + focusGroup: widget.focusGroup, + isRowEntryPoint: widget.isRowEntryPoint, + lockHorizontalNavigation: widget.lockHorizontalNavigation, + delegateHorizontalNavigation: widget.delegateHorizontalNavigation, + context: context, + ), + ); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/modules/ensemble/lib/framework/tv/tv_focus_theme.dart b/modules/ensemble/lib/framework/tv/tv_focus_theme.dart new file mode 100644 index 000000000..cf9ffa6b7 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_theme.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +/// Holds TV focus styling configuration parsed from theme.yaml. +/// +/// This is the highest priority source for TV focus styling, followed by +/// [TVFocusProvider] values, then default fallbacks. +/// +/// ## Theme YAML Configuration +/// +/// ```yaml +/// Common: +/// Tokens: +/// TV: +/// focusBorderColor: 0xFF00AAFF +/// focusBorderWidth: 3 +/// focusBorderRadius: 8 +/// focusAnimationDuration: 150 +/// ``` +class TVFocusTheme { + const TVFocusTheme({ + this.focusBorderColor, + this.focusBorderWidth, + this.focusBorderRadius, + this.focusAnimationDurationMs, + }); + + /// Focus indicator border color from theme. + final Color? focusBorderColor; + + /// Focus indicator border width from theme. + final double? focusBorderWidth; + + /// Focus indicator border radius from theme. + final double? focusBorderRadius; + + /// Focus animation duration in milliseconds from theme. + final int? focusAnimationDurationMs; + + /// Default values for border width, radius, and animation. + /// Note: focusBorderColor defaults to app's primary color (passed at resolve time). + static const double defaultBorderWidth = 3.0; + static const double defaultBorderRadius = 8.0; + static const int defaultAnimationDurationMs = 150; + + /// Creates a copy with non-null values from [other] taking precedence. + TVFocusTheme mergeWith(TVFocusTheme? other) { + if (other == null) return this; + return TVFocusTheme( + focusBorderColor: other.focusBorderColor ?? focusBorderColor, + focusBorderWidth: other.focusBorderWidth ?? focusBorderWidth, + focusBorderRadius: other.focusBorderRadius ?? focusBorderRadius, + focusAnimationDurationMs: + other.focusAnimationDurationMs ?? focusAnimationDurationMs, + ); + } + + /// Resolves the final focus border color with fallback chain. + /// + /// Priority: this.focusBorderColor > providerColor > appPrimaryColor + /// + /// [appPrimaryColor] is typically `Theme.of(context).colorScheme.primary` + Color resolveFocusBorderColor(Color? providerColor, Color appPrimaryColor) { + return focusBorderColor ?? providerColor ?? appPrimaryColor; + } + + /// Resolves the final border width with fallback chain. + double resolveBorderWidth(double? providerWidth) { + return focusBorderWidth ?? providerWidth ?? defaultBorderWidth; + } + + /// Resolves the final border radius with fallback chain. + double resolveBorderRadius(double? providerRadius) { + return focusBorderRadius ?? providerRadius ?? defaultBorderRadius; + } + + /// Resolves the final animation duration with fallback chain. + Duration resolveAnimationDuration(int? providerDurationMs) { + final ms = focusAnimationDurationMs ?? + providerDurationMs ?? + defaultAnimationDurationMs; + return Duration(milliseconds: ms); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is TVFocusTheme && + other.focusBorderColor == focusBorderColor && + other.focusBorderWidth == focusBorderWidth && + other.focusBorderRadius == focusBorderRadius && + other.focusAnimationDurationMs == focusAnimationDurationMs; + } + + @override + int get hashCode => Object.hash( + focusBorderColor, + focusBorderWidth, + focusBorderRadius, + focusAnimationDurationMs, + ); +} diff --git a/modules/ensemble/lib/framework/tv/tv_focus_widget.dart b/modules/ensemble/lib/framework/tv/tv_focus_widget.dart new file mode 100644 index 000000000..c277fa20b --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_widget.dart @@ -0,0 +1,381 @@ +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_registry.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +// ============================================================================= +// TVFocusWidget - Ensemble's Built-in D-pad Navigation +// ============================================================================= + +/// Wraps a focusable child with D-pad navigation. Used when no external +/// TVFocusProvider is supplied (standalone Ensemble apps). +/// +/// ## How It Works +/// 1. Intercepts arrow key events (UP/DOWN/LEFT/RIGHT) +/// 2. Scans FocusTraversalGroup for all TVFocusOrder widgets +/// 3. Builds 2D grid, moves focus based on row/order coordinates +/// 4. Calls edge handlers when at grid boundaries (for scrollbar navigation) +/// +/// ## Key Architecture Note +/// Uses FocusScope (not Focus) so key events bubble up from child. +/// With plain Focus, this node would be a sibling and miss key events. +class TVFocusWidget extends StatelessWidget { + const TVFocusWidget({ + super.key, + required this.focusOrder, + required this.child, + this.onBackPressed, + this.onRightEdge, + this.onLeftEdge, + this.onTopEdge, + this.onBottomEdge, + this.primaryFocusNode, + }); + + /// The focus coordinate for this widget + final TVFocusOrder focusOrder; + + /// The child widget (should be focusable, e.g., InkWell) + final Widget child; + + /// Optional callback when back button is pressed + final KeyEventResult Function(FocusNode node)? onBackPressed; + + /// Optional callback when RIGHT is pressed at the rightmost edge + /// (when no more items exist in the row). Used for navigating to + /// widgets outside the grid like scrollbars. + final VoidCallback? onRightEdge; + + /// Optional callback when LEFT is pressed at the leftmost edge + final VoidCallback? onLeftEdge; + + /// Optional callback when UP is pressed at the topmost edge + final VoidCallback? onTopEdge; + + /// Optional callback when DOWN is pressed at the bottommost edge + final VoidCallback? onBottomEdge; + + /// Explicit requestable node for this coordinate, when the child owns one. + final FocusNode? primaryFocusNode; + + @override + Widget build(BuildContext context) { + final focusTraversalWidget = FocusTraversalOrder( + order: focusOrder, + // Use FocusScope instead of Focus so that this node becomes the PARENT + // of the child's focus node in the focus tree. This allows key events + // from the child to bubble up through this handler. + // With a plain Focus widget, this node would be a SIBLING to the child's + // focus node, and key events would bypass it entirely. + child: FocusScope( + onKeyEvent: (FocusNode node, KeyEvent event) { + if (event is KeyDownEvent) { + // Handle back button + if (event.logicalKey == LogicalKeyboardKey.goBack) { + final result = onBackPressed?.call(node); + if (result != null) { + return result; + } + } + + // Handle arrow keys + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_moveFocus(context, node, yOffset: 1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_moveFocus(context, node, yOffset: -1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_moveFocus(context, node, xOffset: 1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_moveFocus(context, node, xOffset: -1)) { + return KeyEventResult.handled; + } + } + } + return KeyEventResult.ignored; + }, + child: child, + ), + ); + + final registeredFocusNode = primaryFocusNode; + if (registeredFocusNode == null) { + return focusTraversalWidget; + } + + return TVFocusTargetRegistrar( + focusNode: registeredFocusNode, + focusOrder: focusOrder, + row: focusOrder.row, + order: focusOrder.order, + focusGroup: focusOrder.focusGroup, + isRowEntryPoint: focusOrder.isRowEntryPoint, + lockHorizontalNavigation: focusOrder.lockHorizontalNavigation, + delegateHorizontalNavigation: focusOrder.delegateHorizontalNavigation, + child: focusTraversalWidget, + ); + } + + /// Move focus in the specified direction. + /// Returns true if focus was moved, false if at boundary. + bool _moveFocus( + BuildContext context, + FocusNode current, { + int yOffset = 0, + int xOffset = 0, + }) { + // If delegateHorizontalNavigation is true, let parent handle horizontal keys + // (e.g., for carousel slide switching) + if (xOffset != 0 && focusOrder.delegateHorizontalNavigation) { + return false; + } + + // Find the FocusTraversalGroup this widget belongs to + final focusTraversalGroup = + current.context?.findAncestorWidgetOfExactType(); + + // Check for scope locking + final tvFocusScope = + current.context?.findAncestorWidgetOfExactType(); + final lockScope = tvFocusScope?.lockScope ?? false; + final rightEdgeHandler = onRightEdge ?? tvFocusScope?.onRightEdge; + final leftEdgeHandler = onLeftEdge ?? tvFocusScope?.onLeftEdge; + final bottomEdgeHandler = onBottomEdge ?? tvFocusScope?.onBottomEdge; + final topEdgeHandler = onTopEdge ?? tvFocusScope?.onTopEdge; + final currentFocusGroup = focusOrder.focusGroup; + final route = + current.context != null ? ModalRoute.of(current.context!) : null; + bool matchesFocusGroup(TVFocusOrder order) { + if (currentFocusGroup == null) { + return true; + } + return order.focusGroup == currentFocusGroup; + } + + // Collect all focusable items in the same FocusTraversalGroup + final root = FocusManager.instance.rootScope; + final inScopeByOrder = {}; + + for (final target in TVFocusRegistry.targets( + route: route, + traversalGroup: focusTraversalGroup, + focusGroup: currentFocusGroup, + )) { + final order = target.focusOrder as TVFocusOrder; + TVFocusOrderNode.addPreferredCandidate( + inScopeByOrder, + TVFocusOrderNode( + target.focusNode, + order, + isRegisteredTarget: true, + ), + ); + } + + for (final focusNode in root.descendants) { + // Check if this node is mounted and has context + if (focusNode.context == null) continue; + if (!focusNode.canRequestFocus) continue; + if (!TVFocusOrder.isInRoute(focusNode.context!, route)) continue; + + // Check if in same FocusTraversalGroup + final nodeGroup = focusNode.context + ?.findAncestorWidgetOfExactType(); + if (nodeGroup != focusTraversalGroup) continue; + + // Get the TVFocusOrder for this node + final focusTraversalOrder = focusNode.context + ?.findAncestorWidgetOfExactType(); + if (focusTraversalOrder?.order is TVFocusOrder) { + final order = focusTraversalOrder!.order as TVFocusOrder; + if (!matchesFocusGroup(order)) { + continue; + } + TVFocusOrderNode.addPreferredCandidate( + inScopeByOrder, + TVFocusOrderNode(focusNode, order), + ); + } + } + + if (inScopeByOrder.isEmpty) { + return false; + } + + // Build 2D grid from collected items + final grid = TVFocusOrderNode.buildGrid(inScopeByOrder.values); + if (grid.isEmpty) { + return false; + } + + // Find current position in grid + final y = + grid.indexWhere((row) => row.firstOrNull?.order.row == focusOrder.row); + if (y == -1) { + return false; + } + + // 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) + // so users can navigate back to native content from Ensemble content + if (yOffset == -1 && y == 0) { + if (topEdgeHandler != null) { + topEdgeHandler(); + return true; + } + return false; + } + + final x = + grid[y].indexWhere((node) => node.order.order == focusOrder.order); + if (x == -1) { + return false; + } + + // Calculate target position + int newY; + int newX; + + // For vertical movement, find the nearest row in that direction by actual tvRow value + // For horizontal movement, find the nearest order in that direction + if (yOffset != 0) { + // Vertical movement: find nearest row + newY = _findNearestRow(grid, y, focusOrder.row, yOffset); + // First, try to find an explicit entry point in the new row + final entryPointIndex = _findRowEntryPoint(grid[newY]); + if (entryPointIndex != -1) { + // Entry point found, use it + newX = entryPointIndex; + } else { + // No entry point: preserve current column position (order) + // Try to find the same order value in the new row + final sameOrderIndex = grid[newY] + .indexWhere((node) => node.order.order == focusOrder.order); + if (sameOrderIndex != -1) { + newX = sameOrderIndex; + } else { + // Same order not found, clamp to available range + newX = x.clamp(0, grid[newY].length - 1); + } + } + } else { + // Horizontal movement: stay on same row, find nearest order + newY = y; + final targetOrder = focusOrder.order + xOffset; + final nX = grid[y].indexWhere((node) => node.order.order == targetOrder); + if (nX != -1) { + newX = nX; + } else { + // Clamp to row boundaries + newX = (x + xOffset).clamp(0, grid[y].length - 1); + } + } + + final oldTarget = grid[y][x].focus; + final target = grid[newY][newX].focus; + + // Check if we're at a boundary (focus wouldn't move) + if (oldTarget == target) { + // Check for edge handlers before letting event propagate + // This allows navigation to widgets outside the grid (e.g., scrollbars) + // Priority: widget-level handlers > scope-level handlers + if (xOffset > 0 && rightEdgeHandler != null) { + // At right edge and have handler + if (kDebugMode) { + debugPrint( + '[TVFocusWidget] At right edge - calling onRightEdge handler'); + } + rightEdgeHandler(); + return true; + } else if (xOffset < 0 && leftEdgeHandler != null) { + // At left edge and have handler + if (kDebugMode) { + debugPrint( + '[TVFocusWidget] At left edge - calling onLeftEdge handler'); + } + leftEdgeHandler(); + return true; + } else if (yOffset > 0 && bottomEdgeHandler != null) { + // At bottom edge and have handler + if (kDebugMode) { + debugPrint( + '[TVFocusWidget] At bottom edge - calling onBottomEdge handler'); + } + bottomEdgeHandler(); + return true; + } else if (yOffset < 0 && topEdgeHandler != null) { + // At top edge and have handler + if (kDebugMode) { + debugPrint('[TVFocusWidget] At top edge - calling onTopEdge handler'); + } + topEdgeHandler(); + return true; + } + + // At boundary - let event propagate to parent, unless locked + if (lockScope) { + return true; + } + if (xOffset != 0 && focusOrder.lockHorizontalNavigation) { + return true; + } + return false; + } + + // Request focus on target + // Note: Scrolling is handled by box_wrapper.dart's _onFocusChange() listener + target.requestFocus(); + + // Return true if position changed + return x != newX || y != newY; + } + + /// Find the entry point index in a row. + /// Returns the index of the item marked as entry point, or -1 if none found. + int _findRowEntryPoint(List row) { + for (int i = 0; i < row.length; i++) { + if (row[i].order.isRowEntryPoint) { + return i; + } + } + // No entry point found + return -1; + } + + /// Find the nearest row in the specified direction. + /// Uses actual tvRow values, not array indices. + int _findNearestRow( + List> grid, + int currentY, + double currentRow, + int direction, + ) { + if (direction > 0) { + // Moving down: find first row with tvRow > currentRow + for (int i = currentY + 1; i < grid.length; i++) { + final rowValue = grid[i].firstOrNull?.order.row; + if (rowValue != null && rowValue > currentRow) { + return i; + } + } + // No row found below, stay at current + return currentY; + } else { + // Moving up: find last row with tvRow < currentRow + for (int i = currentY - 1; i >= 0; i--) { + final rowValue = grid[i].firstOrNull?.order.row; + if (rowValue != null && rowValue < currentRow) { + return i; + } + } + // No row found above, stay at current + return currentY; + } + } +} diff --git a/modules/ensemble/lib/framework/tv/tv_scrollbar_widget.dart b/modules/ensemble/lib/framework/tv/tv_scrollbar_widget.dart new file mode 100644 index 000000000..cde064d7e --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_scrollbar_widget.dart @@ -0,0 +1,260 @@ +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +// ============================================================================= +// TVScrollbarWidget - Focusable Scrollbar for D-pad Navigation +// ============================================================================= + +/// Focusable scrollbar for TV. Syncs with ListView's ScrollController. +/// +/// ## Visibility +/// - **Hidden** when content fits in viewport (no scrolling needed) +/// - **Visible** when content overflows (scrolling available) +/// +/// ## Visual States (when visible) +/// - Unfocused: Grey, thin (3px default) - always visible if scrollable +/// - Focused: White, wider (6px default) +/// +/// ## Navigation Flow +/// 1. User presses RIGHT at content edge → onRightEdge triggers → scrollbar gains focus +/// 2. User presses UP/DOWN → scrolls content 20% per press +/// 3. User presses LEFT → returns focus to content +/// +/// ## YAML Configuration +/// ```yaml +/// styles: +/// tvOptions: +/// scrollbarOptions: +/// position: right # 'left' or 'right' +/// color: 0xFF666666 # unfocused color (visible when scrollable) +/// focusedColor: 0xFFFFFFFF +/// ``` +class TVScrollbarWidget extends StatefulWidget { + const TVScrollbarWidget({ + super.key, + required this.scrollController, + required this.options, + }); + + /// ScrollController from the scrollable content (ListView/Column) + final ScrollController scrollController; + + /// Scrollbar styling options from YAML + final TVScrollbarOptionsComposite options; + + @override + State createState() => _TVScrollbarWidgetState(); +} + +class _TVScrollbarWidgetState extends State { + late final FocusNode _focusNode; + bool _isFocused = false; + double _thumbOffset = 0.0; + double _thumbHeight = 0.0; + bool _isScrollable = false; + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'TVScrollbar'); + widget.scrollController.addListener(_onScrollChange); + + // Initialize thumb position once controller is ready + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeIfReady(); + }); + } + + void _initializeIfReady() { + if (_isInitialized || !mounted) return; + + if (widget.scrollController.hasClients) { + _isInitialized = true; + _updateThumbPosition(); + setState(() {}); + } else { + // Controller not ready yet, try again next frame + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeIfReady(); + }); + } + } + + /// Public method to request focus on this scrollbar (called from ListView) + void requestFocusOnScrollbar() { + _focusNode.requestFocus(); + } + + @override + void dispose() { + widget.scrollController.removeListener(_onScrollChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onScrollChange() { + if (mounted && widget.scrollController.hasClients) { + setState(() { + _updateThumbPosition(); + }); + } + } + + void _updateThumbPosition() { + if (!widget.scrollController.hasClients) return; + + final position = widget.scrollController.position; + final viewportHeight = position.viewportDimension; + final contentHeight = position.maxScrollExtent + viewportHeight; + final scrollOffset = position.pixels; + + // Check if content is scrollable (content exceeds viewport) + _isScrollable = position.maxScrollExtent > 0; + + if (!_isScrollable) { + // No scrollable content, hide the thumb + _thumbHeight = 0.0; + _thumbOffset = 0.0; + return; + } + + // Calculate thumb height (proportional to viewport/content ratio) + final thumbRatio = viewportHeight / contentHeight; + _thumbHeight = (viewportHeight * thumbRatio).clamp( + widget.options.thumbHeight, + viewportHeight, + ); + + // Calculate thumb offset based on scroll position + final maxThumbOffset = viewportHeight - _thumbHeight; + final scrollRatio = contentHeight > viewportHeight + ? scrollOffset / (contentHeight - viewportHeight) + : 0.0; + _thumbOffset = (maxThumbOffset * scrollRatio).clamp(0.0, maxThumbOffset); + } + + void _scrollDown() { + if (!widget.scrollController.hasClients) return; + + final position = widget.scrollController.position; + final viewportHeight = position.viewportDimension; + final scrollStep = viewportHeight * 0.2; // Scroll 20% of viewport + + final newOffset = (position.pixels + scrollStep).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + + widget.scrollController.animateTo( + newOffset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + void _scrollUp() { + if (!widget.scrollController.hasClients) return; + + final position = widget.scrollController.position; + final viewportHeight = position.viewportDimension; + final scrollStep = viewportHeight * 0.2; // Scroll 20% of viewport + + final newOffset = (position.pixels - scrollStep).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + + widget.scrollController.animateTo( + newOffset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + // Hide scrollbar completely if content is not scrollable + if (!_isScrollable) { + return const SizedBox.shrink(); + } + + // Focus is requested via TVFocusScope edge handlers when user navigates to content boundary + return LayoutBuilder( + builder: (context, constraints) { + final trackHeight = constraints.maxHeight; + + // Use Focus widget with onKeyEvent for UP/DOWN scrolling + // InkWell provides focusability and integrates with directional focus + return Focus( + onKeyEvent: (node, event) { + // Only handle when we have focus + if (!_isFocused || event is! KeyDownEvent) return KeyEventResult.ignored; + + // Handle UP/DOWN for manual scrolling + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + _scrollDown(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + _scrollUp(); + return KeyEventResult.handled; + } + + // Handle LEFT/RIGHT to return focus to content based on scrollbar position + // When scrollbar is on right, LEFT returns to content + // When scrollbar is on left, RIGHT returns to content + if (widget.options.position == 'right' && + event.logicalKey == LogicalKeyboardKey.arrowLeft) { + return KeyEventResult.ignored; // Let focus system handle it + } else if (widget.options.position == 'left' && + event.logicalKey == LogicalKeyboardKey.arrowRight) { + return KeyEventResult.ignored; // Let focus system handle it + } + + return KeyEventResult.ignored; + }, + child: InkWell( + focusNode: _focusNode, + autofocus: widget.options.autofocus, + onTap: () {}, + onFocusChange: (hasFocus) { + if (mounted) { + setState(() { + _isFocused = hasFocus; + }); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: _isFocused ? widget.options.focusedWidth : widget.options.width, + height: trackHeight, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(widget.options.radius), + ), + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 150), + left: 0, + top: _thumbOffset, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: _isFocused ? widget.options.focusedWidth : widget.options.width, + height: _thumbHeight, + decoration: BoxDecoration( + color: _isFocused ? widget.options.focusedColor : widget.options.color, + borderRadius: BorderRadius.circular(widget.options.radius), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/modules/ensemble/lib/framework/view/page.dart b/modules/ensemble/lib/framework/view/page.dart index 218b7387d..90ee08f9e 100644 --- a/modules/ensemble/lib/framework/view/page.dart +++ b/modules/ensemble/lib/framework/view/page.dart @@ -25,6 +25,9 @@ import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:ensemble/widget/helpers/unfocus.dart'; import 'package:ensemble/framework/bindings.dart'; +import 'package:ensemble/framework/device.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; import 'package:flutter/material.dart'; class SinglePageController extends WidgetController { @@ -816,6 +819,17 @@ class PageState extends State rtn = HasSelectableText(child: rtn); } + // TV: Wrap with FocusTraversalGroup for standalone D-pad navigation. + // Skip if external provider exists (host app manages its own focus grid). + final isStandaloneTV = Device().isTV && + TVFocusProviderScope.maybeOf(context) == null; + if (isStandaloneTV) { + rtn = FocusTraversalGroup( + policy: TVFocusOrderTraversalPolicy(), + child: rtn, + ); + } + // if backgroundImage is set, put it outside of the Scaffold so // keyboard sliding up (when entering value) won't resize the background if (backgroundImage != null) { diff --git a/modules/ensemble/lib/framework/widget/view_util.dart b/modules/ensemble/lib/framework/widget/view_util.dart index dc15e4809..e6a7519ca 100644 --- a/modules/ensemble/lib/framework/widget/view_util.dart +++ b/modules/ensemble/lib/framework/widget/view_util.dart @@ -14,6 +14,7 @@ import 'package:ensemble/framework/widget/widget.dart'; import 'package:ensemble/page_model.dart'; import 'package:ensemble/util/gesture_detector.dart'; import 'package:ensemble/widget/custom_widget/custom_widget_model.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:ensemble/widget/radio/radio_button.dart'; import 'package:ensemble/widget/radio/radio_button_controller.dart'; import 'package:ensemble/widget/widget_registry.dart'; @@ -390,6 +391,14 @@ class ViewUtil { String? id = model.props['id']?.toString(); if (id != null) { invokable.id = id; + // Also set the ID on the controller so BoxWrapper can access it + // for TV focus binding dispatch + if (invokable is HasController) { + final controller = (invokable as HasController).controller; + if (controller is WidgetController) { + controller.id = id; + } + } currentScope.dataContext.addInvokableContext(id, invokable); } diff --git a/modules/ensemble/lib/framework/widget/widget.dart b/modules/ensemble/lib/framework/widget/widget.dart index 6e580e633..142a45034 100644 --- a/modules/ensemble/lib/framework/widget/widget.dart +++ b/modules/ensemble/lib/framework/widget/widget.dart @@ -1,5 +1,6 @@ import 'package:ensemble/framework/bindings.dart'; import 'package:ensemble/framework/config.dart'; +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/framework/studio/studio_debugger.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; @@ -114,8 +115,16 @@ abstract class EWidgetState // Handle standalone opacity // Apply only if visibilityTransitionDuration is NOT set (to avoid double wrapping) + // TV: Skip if tvOptions.opacity is set (wrapper handles both focused/unfocused) + final tvOptions = widgetController is BoxController + ? widgetController.tvOptions + : null; + final bool tvHandlesOpacity = Device().isTV && + tvOptions?.isEnabled == true && + tvOptions?.opacity != null; if (widgetController.visibilityTransitionDuration == null && - widgetController.opacity != null) { + widgetController.opacity != null && + !tvHandlesOpacity) { rtn = Opacity( opacity: Utils.optionalDouble(widgetController.opacity!, min: 0, max: 1.0) ?? 1.0, child: rtn, diff --git a/modules/ensemble/lib/layout/grid_view.dart b/modules/ensemble/lib/layout/grid_view.dart index 02aa0b1d7..af6633854 100644 --- a/modules/ensemble/lib/layout/grid_view.dart +++ b/modules/ensemble/lib/layout/grid_view.dart @@ -325,15 +325,24 @@ class GridViewState extends EWidgetState with TemplatedWidgetState { ScreenController() .executeAction(context, widget._controller.onScrollEnd!); } + + // Build widget first to handle null case (e.g., bad template data) + // before wrapping with gesture detector + Widget? itemWidget = buildWidgetForIndex( + context, _items, widget._controller.itemTemplate!, index); + + if (itemWidget == null) { + return const SizedBox.shrink(); + } + if (widget._controller.onItemTap != null) { - return EnsembleGestureDetector( + itemWidget = EnsembleGestureDetector( onTap: (() => _onItemTap(index)), - child: buildWidgetForIndex( - context, _items, widget._controller.itemTemplate!, index), + child: itemWidget, ); } - return buildWidgetForIndex( - context, _items, widget._controller.itemTemplate!, index); + + return itemWidget; } void _onItemTap(int index) { diff --git a/modules/ensemble/lib/layout/list_view.dart b/modules/ensemble/lib/layout/list_view.dart index c80738f4d..cb280dca9 100644 --- a/modules/ensemble/lib/layout/list_view.dart +++ b/modules/ensemble/lib/layout/list_view.dart @@ -1,9 +1,12 @@ import 'package:ensemble/action/haptic_action.dart'; import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/event.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/framework/studio/studio_debugger.dart'; +import 'package:ensemble/framework/tv/tv_scrollbar_widget.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/view/footer.dart'; import 'package:ensemble/framework/widget/has_children.dart'; @@ -401,6 +404,62 @@ class ListViewState extends EWidgetState options: pullToRefresh!, contentWidget: listView); } + // TV: Add focusable scrollbar if configured + if (Device().isTV && widget._controller.tvOptions?.scrollbarOptions != null) { + final scrollbarOptions = widget._controller.tvOptions!.scrollbarOptions!; + final scrollController = widget._controller.scrollController; + + if (scrollController != null) { + // Store scrollbar widget with key to access later + final scrollbarKey = flutter.GlobalKey>(); + final scrollbarWidget = TVScrollbarWidget( + key: scrollbarKey, + scrollController: scrollController, + options: scrollbarOptions, + ); + + // Callback to request focus on scrollbar + void requestScrollbarFocus() { + final scrollbarState = scrollbarKey.currentState; + if (scrollbarState != null) { + (scrollbarState as dynamic).requestFocusOnScrollbar(); + } + } + + // Determine scrollbar position and which edge handler to use + final isLeftPosition = scrollbarOptions.position == 'left'; + + // Wrap content with TVFocusScope that handles edge navigation + final scopedContent = TVFocusScope( + lockScope: false, + // Set edge handler based on scrollbar position + onRightEdge: isLeftPosition ? null : requestScrollbarFocus, + onLeftEdge: isLeftPosition ? requestScrollbarFocus : null, + child: listView, + ); + + // Build Row with scrollbar on correct side + listView = flutter.Row( + crossAxisAlignment: flutter.CrossAxisAlignment.stretch, + children: isLeftPosition + ? [ + flutter.FocusTraversalGroup( + policy: flutter.WidgetOrderTraversalPolicy(), + child: scrollbarWidget, + ), + flutter.Expanded(child: scopedContent), + ] + : [ + flutter.Expanded(child: scopedContent), + flutter.FocusTraversalGroup( + policy: flutter.WidgetOrderTraversalPolicy(), + child: scrollbarWidget, + ), + ], + ); + } + } + return BoxWrapper( boxController: widget._controller, widget: DefaultTextStyle.merge( diff --git a/modules/ensemble/lib/layout/tab/base_tab_bar.dart b/modules/ensemble/lib/layout/tab/base_tab_bar.dart index 3e2c085e0..e0a84595f 100644 --- a/modules/ensemble/lib/layout/tab/base_tab_bar.dart +++ b/modules/ensemble/lib/layout/tab/base_tab_bar.dart @@ -1,6 +1,9 @@ +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/extensions.dart'; import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_widget.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/widget/widget.dart'; import 'package:ensemble/layout/tab/tab_bar_controller.dart'; @@ -8,6 +11,13 @@ import 'package:ensemble/layout/tab_bar.dart'; import 'package:flutter/material.dart'; import 'package:ensemble/framework/widget/icon.dart' as ensemble; +// ============================================================================= +// BaseTabBarState - Shared TabBar Logic for Mobile and TV +// ============================================================================= + +/// Base state for TabBar widgets. Handles tab building, styling, and navigation. +/// On TV: Uses [_TVTabButton] with TVFocusOrder for D-pad navigation. +/// On Mobile: Uses Flutter's standard TabBar widget. abstract class BaseTabBarState extends EWidgetState with TickerProviderStateMixin, TabBarAction { late TabController tabController; @@ -27,6 +37,12 @@ abstract class BaseTabBarState extends EWidgetState /// build the Tab Bar navigation part Widget buildTabBar() { + // TV Navigation: Use custom focusable tab buttons instead of Flutter TabBar + // This follows flutter_pca's pattern where each tab is an individually focusable button + if (Device().isTV) { + return _buildTVTabBar(); + } + TextStyle? tabStyle = TextStyle( fontSize: widget.controller.tabFontSize?.toDouble(), fontWeight: widget.controller.tabFontWeight); @@ -104,6 +120,104 @@ abstract class BaseTabBarState extends EWidgetState return tabBar; } + /// Build TV-specific tab bar with individually focusable buttons. + /// Uses flutter_pca-style TVFocusOrder coordinates for navigation. + /// + /// If tvRow is set on the controller, tabs participate in the main page focus grid. + /// Otherwise, tabs are wrapped in their own FocusTraversalGroup. + Widget _buildTVTabBar() { + final items = widget.controller.items; + final activeColor = widget.controller.activeTabColor ?? + Theme.of(context).colorScheme.primary; + final inactiveColor = widget.controller.inactiveTabColor ?? Colors.black87; + final indicatorColor = widget.controller.indicatorColor ?? activeColor; + final backgroundColor = widget.controller.tabBackgroundColor; + final indicatorThickness = + widget.controller.indicatorThickness?.toDouble() ?? 2; + + // Get tvOptions for row + final tvOptions = widget.controller.tvOptions; + final tvRow = tvOptions?.row ?? 0.0; + + // Use AnimatedBuilder to rebuild tabs when selection changes + // This mirrors Flutter's TabBar which listens to tabController.animation + Widget tabBar = AnimatedBuilder( + animation: tabController, + builder: (context, child) { + Widget tabRow = SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(items.length, (index) { + final tabItem = items[index]; + + return _TVTabButton( + key: ValueKey('tv_tab_$index'), + tabItem: tabItem, + index: index, + tabRow: tvRow, + isSelected: tabController.index == index, + autofocus: index == 0, // First tab gets autofocus + activeColor: activeColor, + inactiveColor: inactiveColor, + indicatorColor: indicatorColor, + indicatorThickness: indicatorThickness, + tabFontSize: widget.controller.tabFontSize?.toDouble(), + tabFontWeight: widget.controller.tabFontWeight, + tabPadding: widget.controller.tabPadding, + onTap: () { + tabController.animateTo(index); + onTabChanged(index); + }, + ); + }), + ), + ); + + // Apply tabBarPadding to the tabs row only (not content area) + if (widget.controller.tabBarPadding != null) { + tabRow = Padding( + padding: widget.controller.tabBarPadding!, + child: tabRow, + ); + } + + return tabRow; + }, + ); + + // If tvRow is 0 (default), wrap tabs in their own FocusTraversalGroup + if (tvOptions?.row == null) { + tabBar = FocusTraversalGroup( + policy: TVFocusOrderTraversalPolicy(), + child: tabBar, + ); + } + + if (backgroundColor != null) { + tabBar = ColoredBox(color: backgroundColor, child: tabBar); + } + + if (widget.controller.borderRadius != null) { + final borderRadius = widget.controller.borderRadius?.getValue(); + tabBar = Container( + decoration: BoxDecoration( + border: Border.all( + color: widget.controller.borderColor ?? Colors.transparent, + width: (widget.controller.borderWidth ?? 0.0).toDouble(), + ), + borderRadius: borderRadius ?? BorderRadius.zero, + ), + child: ClipRRect( + borderRadius: borderRadius ?? BorderRadius.zero, + child: tabBar + ), + ); + } + + return tabBar; + } + List _buildTabs(List items) { List tabItems = []; for (final tabItem in items) { @@ -133,3 +247,197 @@ abstract class BaseTabBarState extends EWidgetState mixin TabBarAction on EWidgetState { void changeTab(int index); } + +/// TV-specific focusable tab button using flutter_pca-style navigation. +/// Each tab uses TVFocusOrder coordinates. +/// If TabBar has tvRow set, tabs use that row in the main page grid. +/// Otherwise, tabs use row 0 within an isolated FocusTraversalGroup. +class _TVTabButton extends StatefulWidget { + const _TVTabButton({ + super.key, + required this.tabItem, + required this.index, + required this.tabRow, + required this.isSelected, + required this.activeColor, + required this.inactiveColor, + required this.indicatorColor, + required this.indicatorThickness, + required this.onTap, + this.autofocus = false, + this.tabFontSize, + this.tabFontWeight, + this.tabPadding, + }); + + final TabItem tabItem; + final int index; + final double tabRow; + final bool isSelected; + final bool autofocus; + final Color activeColor; + final Color inactiveColor; + final Color indicatorColor; + final double indicatorThickness; + final VoidCallback onTap; + final double? tabFontSize; + final FontWeight? tabFontWeight; + final EdgeInsets? tabPadding; + + @override + State<_TVTabButton> createState() => _TVTabButtonState(); +} + +class _TVTabButtonState extends State<_TVTabButton> { + late final FocusNode _focusNode; + bool _hasReceivedFocus = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'TVTabButton_${widget.index}'); + _focusNode.addListener(_onFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onFocusChange() { + // Only scroll when focus is gained (not on initial autofocus to avoid blocking content) + if (_focusNode.hasFocus && _hasReceivedFocus && mounted) { + _scrollIntoView(); + } + // Mark that we've received focus at least once + if (_focusNode.hasFocus) { + _hasReceivedFocus = true; + } + } + + void _scrollIntoView() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Scrollable.ensureVisible( + context, + alignment: 0.0, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + final padding = widget.tabPadding ?? + const EdgeInsets.only(left: 0, right: 30, top: 0, bottom: 0); + + // Build the tab content with InkWell for focus support + Widget inkWell = InkWell( + focusNode: _focusNode, + autofocus: widget.autofocus, + // Disable visual effects - we use indicator instead + splashColor: Colors.transparent, + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + highlightColor: Colors.transparent, + overlayColor: WidgetStateProperty.all(Colors.transparent), + splashFactory: NoSplash.splashFactory, + onTap: widget.onTap, + child: Builder( + builder: (builderContext) { + // Get focus state from InkWell's Focus + final hasFocus = Focus.maybeOf(builderContext)?.hasFocus ?? false; + return Container( + padding: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Tab content (icon + label) + _buildTabContent(context, hasFocus), + const SizedBox(height: 4), + // Indicator line (shows when selected or focused) + AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: widget.indicatorThickness, + width: hasFocus ? 24 : (widget.isSelected ? 16 : 0), + decoration: BoxDecoration( + color: hasFocus + ? Colors.blue + : widget.isSelected + ? widget.indicatorColor + : Colors.transparent, + borderRadius: BorderRadius.circular(widget.indicatorThickness / 2), + ), + ), + ], + ), + ); + }, + ), + ); + + // Wrap with TVFocusWidget for D-pad navigation. + // Uses tabRow from TabBar controller (either tvRow from YAML or 0 for isolated group). + // Order = index for left/right navigation. + // The selected tab is marked as entry point so it gets focus when entering the row. + return TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + widget.tabRow, + order: widget.index.toDouble(), + isRowEntryPoint: widget.isSelected, // selected tab is the entry point + ), + child: inkWell, + ); + } + + Widget _buildTabContent(BuildContext context, bool isFocused) { + final textColor = isFocused + ? Colors.white + : widget.isSelected + ? widget.activeColor + : widget.inactiveColor; + + final textStyle = TextStyle( + fontSize: widget.tabFontSize ?? 14, + fontWeight: widget.tabFontWeight ?? (widget.isSelected ? FontWeight.w600 : FontWeight.normal), + color: textColor, + ); + + // If a custom tabWidget is defined, render it instead of icon+label + if (widget.tabItem.tabWidget != null) { + ScopeManager? scopeManager = DataScopeWidget.getScope(context); + if (scopeManager != null) { + return scopeManager.buildWidgetFromDefinition(widget.tabItem.tabWidget); + } + } + + // Build icon if present + Widget? iconWidget; + if (widget.tabItem.icon != null) { + iconWidget = ensemble.Icon.fromModel(widget.tabItem.icon!); + } + + // Build label + final label = widget.tabItem.label ?? ''; + + if (iconWidget != null && label.isNotEmpty) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + iconWidget, + const SizedBox(width: 8), + Text(label, style: textStyle), + ], + ); + } else if (iconWidget != null) { + return iconWidget; + } else { + return Text(label, style: textStyle); + } + } +} diff --git a/modules/ensemble/lib/layout/tab/scrollable_tab_bar.dart b/modules/ensemble/lib/layout/tab/scrollable_tab_bar.dart index 6cd99fc7f..c7b2846ff 100644 --- a/modules/ensemble/lib/layout/tab/scrollable_tab_bar.dart +++ b/modules/ensemble/lib/layout/tab/scrollable_tab_bar.dart @@ -52,6 +52,8 @@ class ScrollableTabBar extends BaseTabBar { 'margin': (margin) => _controller.margin = Utils.optionalInsets(margin), 'tabPadding': (padding) => _controller.tabPadding = Utils.optionalInsets(padding), + 'tabBarPadding': (padding) => + _controller.tabBarPadding = Utils.optionalInsets(padding), 'tabFontSize': (fontSize) => _controller.tabFontSize = Utils.optionalInt(fontSize), 'tabFontWeight': (fontWeight) => diff --git a/modules/ensemble/lib/layout/tab/tab_bar_controller.dart b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart index 38e448c1b..5a88b099b 100644 --- a/modules/ensemble/lib/layout/tab/tab_bar_controller.dart +++ b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart @@ -7,13 +7,18 @@ import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:flutter/cupertino.dart'; import 'package:yaml/yaml.dart'; -// the Controller for the TabBar +// ============================================================================= +// TabBarController - State & Configuration for TabBar Widget +// ============================================================================= + +/// Controller for TabBar widgets. Manages styling, tab items, and selection state. class TabBarController extends BoxController { String? tabPosition; String? tabAlignment; String? indicatorSize; String? tabType; EdgeInsets? tabPadding; + EdgeInsets? tabBarPadding; // Padding around the entire tab bar row (not content) int? tabFontSize; FontWeight? tabFontWeight; Color? tabBackgroundColor; @@ -24,6 +29,11 @@ class TabBarController extends BoxController { Color? dividerColor; int? indicatorThickness; + /// TV Navigation: The row position for tab buttons in the focus grid. + /// If set, tabs participate in the main page focus grid at this row. + /// If not set, tabs are in an isolated focus group. + double? tvRow; + EnsembleAction? onTabSelection; String? onTabSelectionHaptic; TabBarAction? tabBarAction; @@ -67,6 +77,7 @@ class TabBarController extends BoxController { var setters = super.getBaseSetters(); setters.addAll({ 'items': (values) => items = values, + 'tvRow': (value) => tvRow = Utils.optionalDouble(value), }); return setters; } diff --git a/modules/ensemble/lib/layout/tab_bar.dart b/modules/ensemble/lib/layout/tab_bar.dart index 648ab60c4..0de417f20 100644 --- a/modules/ensemble/lib/layout/tab_bar.dart +++ b/modules/ensemble/lib/layout/tab_bar.dart @@ -11,18 +11,24 @@ import 'package:ensemble/util/utils.dart'; import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; import 'package:flutter/material.dart'; -/// TabBar navigation only +// ============================================================================= +// TabBar Widgets - Navigation Tabs with Content Areas +// ============================================================================= + +/// TabBar navigation only (no body content). Use when tabs control external content. class TabBarOnly extends BaseTabBar { static const type = 'TabBarOnly'; TabBarOnly({super.key}); } -/// full TabBar container +/// Full TabBar with body content. Each tab has associated bodyWidget. class TabBarContainer extends BaseTabBar { static const type = 'TabBar'; TabBarContainer({super.key}); } +/// Abstract base for TabBar widgets. Defines YAML setters and invokable methods. +/// State logic is in [TabBarState], rendering is in [BaseTabBarState]. abstract class BaseTabBar extends StatefulWidget with Invokable, HasController { BaseTabBar({Key? key}) : super(key: key); @@ -66,6 +72,8 @@ abstract class BaseTabBar extends StatefulWidget 'margin': (margin) => _controller.margin = Utils.optionalInsets(margin), 'tabPadding': (padding) => _controller.tabPadding = Utils.optionalInsets(padding), + 'tabBarPadding': (padding) => + _controller.tabBarPadding = Utils.optionalInsets(padding), 'tabFontSize': (fontSize) => _controller.tabFontSize = Utils.optionalInt(fontSize), 'tabFontWeight': (fontWeight) => @@ -98,8 +106,10 @@ abstract class BaseTabBar extends StatefulWidget } } +/// State for TabBar widgets. Manages tab controller lifecycle, conditional tabs, +/// and tab content building (single or indexed mode). class TabBarState extends BaseTabBarState { - // Cache for indexed tab building mode + /// Cache for indexed tab building mode (useIndexedTab: true) late List _cache; @override @@ -320,16 +330,9 @@ class TabBarState extends BaseTabBarState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ buildTabBar(), - // builder gives us dynamic height control vs TabBarView, but - // is sub-optimal since it recreates the tab content on each pass. - // This means onLoad API may be called multiple times in debug mode + // Builder gives dynamic height vs TabBarView, but recreates content each pass. + // onLoad may fire multiple times in debug mode. tabContent, - - // This cause Expanded child to fail - // Padding( - // padding: const EdgeInsets.only(left: 0), - // child: Builder(builder: (BuildContext context) => buildSelectedTab()) - // ) ], ); // if Expanded is set, stretch our column to left-over height diff --git a/modules/ensemble/lib/screen_controller.dart b/modules/ensemble/lib/screen_controller.dart index cd7f02fd1..8a39c0fea 100644 --- a/modules/ensemble/lib/screen_controller.dart +++ b/modules/ensemble/lib/screen_controller.dart @@ -19,6 +19,7 @@ import 'package:ensemble/framework/stub/camera_manager.dart'; import 'package:ensemble/framework/stub/face_camera_manager.dart'; import 'package:ensemble/framework/theme/theme_loader.dart'; import 'package:ensemble/framework/theme_manager.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/view/page.dart' as ensemble; import 'package:ensemble/framework/view/page_group.dart'; @@ -569,6 +570,24 @@ class ScreenController { isExternal: isExternal, ); + // When navigating externally (asExternal: true), wrap screen with Theme + // to ensure theme (including TV focus styling) continues to work on the external navigator. + // + // IMPORTANT: We intentionally do NOT wrap with TVFocusProviderScope here because: + // - TVFocusProviderScope interferes with TabBar and TextInput focus handling + // - For external pages, TV focus colors must come from Theme (via Tokens.TV in theme.yaml) + // - The TVFocusProvider from host app only works on pages inside EnsembleApp's navigator + if (asExternal) { + // Get theme from EnsembleThemeManager + final ensembleThemeData = EnsembleThemeManager().currentTheme()?.appThemeData; + if (ensembleThemeData != null) { + screenWidget = Theme( + data: ensembleThemeData, + child: screenWidget, + ); + } + } + Map? defaultTransitionOptions = Theme.of(context).extension()?.transitions ?? {}; diff --git a/modules/ensemble/lib/widget/button.dart b/modules/ensemble/lib/widget/button.dart index 922e10736..1188d9f0f 100644 --- a/modules/ensemble/lib/widget/button.dart +++ b/modules/ensemble/lib/widget/button.dart @@ -130,6 +130,7 @@ class ButtonState extends EWidgetState