diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..9c51525d4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Store text files with LF line endings. +# +# The android_TV_implementation branch (PR #2173) had its whole tree rewritten +# from LF to CRLF by a bad merge, which inflated the PR diff from ~13k to +# ~101k lines. These rules keep the repository contents normalised to LF so a +# CRLF rewrite cannot produce whole-file diffs again. +* text=auto eol=lf + +# JavaScript parser test fixtures are stored with their original line endings +# (mixed CRLF/LF) and must never be normalised. +packages/parsejs_null_safety/test/testcases/** -text diff --git a/modules/bracket/README.md b/modules/bracket/README.md index b5d4c9102..38a57ff86 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,182 @@ 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 | +| `selectedBorderColor` | Color | | Border color of the selected tab | +| `selectedBorderWidth` | Number | | Border width of the selected tab | +| `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** (`focusBorderColor` / `focusBorderWidth` / `focusBorderRadius`) +2. **Theme** (`EnsembleThemeExtension.tvFocusTheme`) +3. **Provider** (`TVFocusProviderScope`) +4. **tabStyles regular border** (`borderColor` / `borderWidth` / `borderRadius`) +5. **Terminal default (per property):** color → app primary color (`theme.colorScheme.primary`); width → `2.0`; radius → `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 +212,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..c34165da1 100644 --- a/modules/bracket/lib/src/bracket.dart +++ b/modules/bracket/lib/src/bracket.dart @@ -1,15 +1,23 @@ // 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/foundation.dart' + show ValueListenable, ValueNotifier, visibleForTesting; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; /// Implementation of the tournament bracket widget for Ensemble. class EnsembleBracketImpl extends EnsembleWidget @@ -29,6 +37,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 +64,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; @@ -76,6 +87,15 @@ class BracketController extends EnsembleBoxController { /// Gap between tabs. double tabGap = 12; + /// Leading inset inside every round page. + double? leftEdgePadding; + + /// Trailing inset applied only to the final round page. + double? rightEdgePadding; + + /// Symmetric inset for the round-title strip. + double headerPadding = 0; + /// Creates a [BracketController]. BracketController(); @@ -88,6 +108,32 @@ class BracketController extends EnsembleBoxController { TextStyle? tabTextStyle; TextStyle? tabSelectedStyle; EBorderRadius? tabBorderRadius; + Color? tabBorderColor; + double? tabBorderWidth; + Color? tabSelectedBorderColor; + double? tabSelectedBorderWidth; + + // 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 +151,32 @@ 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); + } + }, + 'leftEdgePadding': (value) { + leftEdgePadding = (Utils.optionalDouble(value) ?? 0) + .clamp(0, double.infinity) + .toDouble(); + }, + 'rightEdgePadding': (value) { + rightEdgePadding = (Utils.optionalDouble(value) ?? 0) + .clamp(0, double.infinity) + .toDouble(); + }, + 'headerPadding': (value) { + headerPadding = (Utils.optionalDouble(value) ?? 0) + .clamp(0, double.infinity) + .toDouble(); + }, 'lineStyles': (data) { lineColor = Utils.getColor(data['color']); lineWidth = Utils.optionalDouble(data['width']); @@ -116,8 +188,20 @@ 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']); + tabSelectedBorderColor = Utils.getColor(data['selectedBorderColor']); + tabSelectedBorderWidth = + Utils.optionalDouble(data['selectedBorderWidth']); tabPadding = Utils.optionalInsets(data['padding']); tabGap = Utils.getDouble(data['gap'], fallback: 12.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 +212,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'], @@ -171,9 +255,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 +294,7 @@ class BracketState extends EnsembleWidgetState return BracketsView( controller: widget.controller, data: roundData, + tvRowOffset: widget.controller.tvRowOffset, ); } } @@ -214,11 +302,13 @@ class BracketState extends EnsembleWidgetState class BracketsView extends StatefulWidget { final List data; final BracketController controller; + final int tvRowOffset; const BracketsView({ super.key, required this.data, required this.controller, + this.tvRowOffset = 0, }); @override @@ -228,43 +318,103 @@ class BracketsView extends StatefulWidget { class _BracketsViewState extends State { late PageController _pageController; int _currentPageIndex = 0; + // Target page controls the column layout anchor. + int _prevColumnIndex = 0; List _tabKeys = []; + // Prevents child focus handling from scrolling horizontally. + 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); + _syncTabKeys(); + // 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()); + _syncTabKeys(); + } + + // Regenerate tab keys when the round count changes. + void _syncTabKeys() { + if (_tabKeys.length != widget.data.length) { + _tabKeys = + List.generate(widget.data.length, (index) => GlobalKey()); + } } void _updatePageIndex() { + // The controller is recreated in didUpdateWidget when viewportFraction + // changes; ticks can arrive while the new one has no client yet. + if (!_pageController.hasClients) return; int newPage = _pageController.page!.round(); - if (newPage != _currentPageIndex) { - setState(() { - _currentPageIndex = newPage; - }); - } + if (newPage == _currentPageIndex) return; + _currentPageIndex = newPage; + // On mobile, a horizontal swipe changes the page but not the reference + // lane used for column spacing (_prevColumnIndex). Sync it here so tiles + // realign after a swipe exactly as they do after a tab tap. On TV the + // selected tab follows _prevColumnIndex (driven by + // onPrevColumnIndexChanged) and _currentPageIndex is not read during + // build, so there is nothing to rebuild here mid-slide. + if (Device().isTV) return; + setState(() { + _prevColumnIndex = newPage; + }); + // Nudge the tab strip only when the page actually changes. Running this on + // every PageController tick restarted a 300ms ensureVisible animation frame + // after frame for the whole slide. _scrollToSelectedTab(newPage); } 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) { + // Arrow navigation owns the PageController while a TV transition is in + // flight. Prevent a tab activation from starting a second page activity + // that bypasses BracketsPage's transition generation/lock. + if (Device().isTV && + _pageController.hasClients && + _pageController.position.isScrollingNotifier.value) { + return; + } + // Update _prevColumnIndex before animation to keep the layout anchor + // aligned with the requested page. + setState(() { + _prevColumnIndex = index; + }); _pageController.animateToPage( index, duration: const Duration(milliseconds: 300), @@ -281,18 +431,47 @@ class _BracketsViewState extends State { @override Widget build(BuildContext context) { - return Column( + Widget content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_tabKeys.isNotEmpty) SingleChildScrollView( - padding: EdgeInsets.zero, + padding: EdgeInsets.symmetric( + horizontal: widget.controller.headerPadding, + ), scrollDirection: Axis.horizontal, child: Row( mainAxisAlignment: MainAxisAlignment.start, children: List.generate(widget.data.length, (index) { - bool isSelected = index == _currentPageIndex; + // On TV, tab selection follows _prevColumnIndex — the true + // navigation target — because _pageController.page (which drives + // _currentPageIndex) clamps on the last pages with padEnds:false, + // so the final tab would never register as selected. Mobile keeps + // _currentPageIndex since swipes update it directly. + bool isSelected = index == + (Device().isTV ? _prevColumnIndex : _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, + ); + } + + final Color? mobileBorderColor = isSelected + ? (widget.controller.tabSelectedBorderColor ?? + widget.controller.tabBorderColor) + : widget.controller.tabBorderColor; + final double mobileBorderWidth = isSelected + ? (widget.controller.tabSelectedBorderWidth ?? + widget.controller.tabBorderWidth ?? + 1.0) + : (widget.controller.tabBorderWidth ?? 1.0); + return Container( padding: EdgeInsets.only(left: widget.controller.tabGap), child: ElevatedButton( @@ -303,10 +482,20 @@ class _BracketsViewState extends State { }, style: ElevatedButton.styleFrom( padding: widget.controller.tabPadding, - shape: widget.controller.tabBorderRadius != null + // Draw a resting border only when a border color is set, so + // apps that never set it keep their previous look. + shape: (widget.controller.tabBorderRadius != null || + mobileBorderColor != null) ? RoundedRectangleBorder( - borderRadius: - widget.controller.tabBorderRadius!.getValue(), + borderRadius: widget.controller.tabBorderRadius + ?.getValue() ?? + BorderRadius.zero, + side: mobileBorderColor != null + ? BorderSide( + color: mobileBorderColor, + width: mobileBorderWidth, + ) + : BorderSide.none, ) : null, backgroundColor: isSelected @@ -325,14 +514,210 @@ 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; + }); + _scrollToSelectedTab(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) { + 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; + } + + // Resting-state border comes from the base tabStyles (borderColor/ + // borderWidth); focus overrides it. A border is always painted + // (transparent + focus width when unset) to prevent size jerk on focus. + // Selected tab may override the base border; priority focused > selected > normal. + final restingBorderColor = widget.isSelected + ? (widget.controller.tabSelectedBorderColor ?? + widget.controller.tabBorderColor ?? + Colors.transparent) + : (widget.controller.tabBorderColor ?? Colors.transparent); + final restingBorderWidth = widget.isSelected + ? (widget.controller.tabSelectedBorderWidth ?? + widget.controller.tabBorderWidth ?? + focusBorderWidth) + : (widget.controller.tabBorderWidth ?? focusBorderWidth); + final borderColor = _isFocused ? focusBorderColor : restingBorderColor; + final borderWidth = _isFocused ? focusBorderWidth : restingBorderWidth; + + 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: borderWidth, + ), + ), + ), + child: Text( + widget.title, + style: textStyle, + ), + ), + ), + ), + ); } } @@ -340,82 +725,649 @@ 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 State createState() => _BracketsPageState(); } -class CustomScrollNotification extends Notification { - final double scrollPosition; - final double maxScrollExtent; - final double visibleHeight; - - CustomScrollNotification( - this.scrollPosition, this.maxScrollExtent, this.visibleHeight); -} - -class _BracketsPageState extends State { - int _prevColumnIndex = 0; +class _BracketsPageState extends State + with SingleTickerProviderStateMixin { late List _scrollControllers; + // Own the FocusScope that wraps this bracket's pages so navigation can search + // ONLY this bracket's focus subtree. A previous BracketView still mounted lower + // in the navigator stack has its own duplicate column tiles in the global focus + // tree; scanning globally can focus those off-screen ghosts (no visible ring). + final FocusScopeNode _pageScopeNode = + FocusScopeNode(debugLabel: 'BracketPageScope'); + final Map<_BracketMatchKey, FocusScopeNode> _matchFocusScopes = {}; + + bool _scrollSyncScheduled = false; + bool _scrollSyncAllScheduled = false; + bool _isSynchronizingScrolls = false; + int? _pendingScrollSyncColumn; + int? _pendingSyncAllExclude; + bool _isHorizontalTransitioning = false; + int _focusRequestGeneration = 0; + /// Identifies the current horizontal transition; a stale completion (e.g. a + /// reflow interrupted by a newer one) must not clear the lock or move focus. + int _transitionGeneration = 0; + /// Timestamp of the last horizontal transition start, used to detect input + /// floods (see [_startHorizontalTransition]). + int _lastHorizontalTransitionAtMs = -10000; + /// Timestamp of the last accepted vertical move. During a held key, repeats + /// inside [_verticalRepeatGateMs] are consumed without starting another + /// scroll/focus chain (bounds per-second allocation churn; see + /// [_moveVertically]). + int _lastVerticalMoveAtMs = -10000; + static const int _verticalRepeatGateMs = 150; + double _logicalScrollOffset = 0; + _BracketMatchKey? _focusedMatch; + late int _activeColumnIndex; + late final ValueNotifier<_BracketLayoutState> _layoutStateNotifier; + late final AnimationController _layoutAnimationController; + @override void initState() { super.initState(); + _activeColumnIndex = widget.prevColumnIndex; + _layoutStateNotifier = ValueNotifier( + _BracketLayoutState.stationary(widget.prevColumnIndex), + ); + _layoutAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + )..addListener(_onLayoutAnimationTick); _scrollControllers = List.generate(widget.data.length, (index) => ScrollController()); } - 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); + @override + void didUpdateWidget(covariant BracketsPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.prevColumnIndex != widget.prevColumnIndex) { + if (Device().isTV) { + // TV transitions commit one stationary geometry state instead of + // animating SliverFixedExtentList.itemExtent. Arrow navigation commits + // it before this rebuild; tab activation commits it here. + if (!_isHorizontalTransitioning) { + _activeColumnIndex = widget.prevColumnIndex; + _layoutStateNotifier.value = + _BracketLayoutState.stationary(widget.prevColumnIndex); + } + } else if (!_isHorizontalTransitioning) { + _activeColumnIndex = widget.prevColumnIndex; + _animateLayoutTo(widget.prevColumnIndex); + } + } + if (oldWidget.data.length != widget.data.length) { + _resizeScrollControllers(); + if (widget.data.isNotEmpty && _activeColumnIndex >= widget.data.length) { + _activeColumnIndex = widget.data.length - 1; + } + } + } - setState(() { - _prevColumnIndex = index; + void _resizeScrollControllers() { + final oldControllers = _scrollControllers; + final retainedCount = oldControllers.length < widget.data.length + ? oldControllers.length + : widget.data.length; + final newControllers = [ + for (var i = 0; i < retainedCount; i++) oldControllers[i], + for (var i = retainedCount; i < widget.data.length; i++) + ScrollController(), + ]; + _scrollControllers = newControllers; + // Old CustomScrollViews still reference the surplus controllers until the + // PageView rebuilds with the new array (same frame). Disposing them here + // throws "used after being disposed" if a tick/animation lands in that + // window, so retire them only once they are actually detached. + for (var i = widget.data.length; i < oldControllers.length; i++) { + _disposeWhenDetached(oldControllers[i]); + } + } + + /// Disposes [controller] post-frame, and only while it has no attached + /// positions. If a retained ghost (e.g. a bracket kept lower in the + /// navigator stack) still holds it, abandon disposal rather than risk a + /// use-after-dispose — the unreferenced controller is simply collected. + void _disposeWhenDetached(ScrollController controller, [int attempt = 0]) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (controller.hasClients) { + if (attempt < 4) { + _disposeWhenDetached(controller, attempt + 1); + } + return; + } + controller.dispose(); }); } + void _onPageChanged(int columnIndex) { + // Synchronize only the newly selected column. + _scheduleScrollSync(columnIndex); + } + + int _matchCount(int columnIndex) { + // The round list can be replaced by a data-binding refresh at any moment; + // stale key events may still reference an old column index. + if (columnIndex < 0 || columnIndex >= widget.data.length) return 0; + final matches = widget.data[columnIndex].localScope.dataContext + .eval(widget.data[columnIndex].matches.data); + return (matches as List?)?.length ?? 0; + } + + double _slotExtent(int columnIndex) { + if (columnIndex < 0 || columnIndex >= widget.data.length) return 0; + return widget.data[columnIndex].matches.height * + _layoutStateNotifier.value.multiplierFor(columnIndex); + } + + void _onLayoutAnimationTick() { + final state = _layoutStateNotifier.value; + _layoutStateNotifier.value = state.copyWith( + progress: + Curves.easeInOutCubic.transform(_layoutAnimationController.value), + ); + } + + Future _animateLayoutTo( + int targetColumn, { + Duration duration = const Duration(milliseconds: 280), + }) { + final current = _layoutStateNotifier.value; + if (current.toColumnIndex == targetColumn && current.progress == 1) { + return Future.value(); + } + _layoutStateNotifier.value = _BracketLayoutState( + fromColumnIndex: current.toColumnIndex, + toColumnIndex: targetColumn, + progress: 0, + ); + // TV drives this concurrently with the page slide and matches its 180ms so + // the reflow and the focus transfer settle together; mobile keeps the + // standalone 280ms reflow. + _layoutAnimationController.duration = duration; + // `TickerFuture.whenComplete` never fires when the animation is interrupted + // (only `orCancel` resolves on cancellation), which would hang any caller + // sequencing on this reflow. Swallow cancellation as a normal completion so + // callers always resume. + return _layoutAnimationController + .forward(from: 0) + .orCancel + .then((_) {}, onError: (Object _) {}); + } + + void _registerMatchFocus(_BracketMatchKey key, FocusScopeNode node) { + _matchFocusScopes[key] = node; + } + + void _unregisterMatchFocus(_BracketMatchKey key, FocusScopeNode node) { + if (identical(_matchFocusScopes[key], node)) { + _matchFocusScopes.remove(key); + } + } + + void _scheduleScrollSync(int columnIndex) { + if (!mounted || + columnIndex < 0 || + columnIndex >= _scrollControllers.length) { + return; + } + _pendingScrollSyncColumn = columnIndex; + if (_scrollSyncScheduled) return; + _scrollSyncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollSyncScheduled = false; + if (!mounted) return; + final pendingColumn = _pendingScrollSyncColumn; + _pendingScrollSyncColumn = null; + if (pendingColumn == null || pendingColumn >= _scrollControllers.length) { + return; + } + final controller = _scrollControllers[pendingColumn]; + if (!controller.hasClients) return; + + _isSynchronizingScrolls = true; + final position = controller.position; + final target = _logicalScrollOffset.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((position.pixels - target).abs() > 0.5) { + controller.jumpTo(target); + } + _isSynchronizingScrolls = false; + }); + } + + void _onColumnScroll(int columnIndex, ScrollNotification notification) { + if (_isSynchronizingScrolls || notification.metrics.axis != Axis.vertical) { + return; + } + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification) { + _logicalScrollOffset = notification.metrics.pixels; + if (notification is ScrollUpdateNotification) { + // Keep the other columns' vertical position in sync live while + // dragging (matching connector lines/match rows mid-drag), but batch + // the jumps into one post-frame pass. Calling jumpTo on every column + // synchronously per scroll notification performs N viewport layouts + // per frame and saturates the UI thread on TV. + _scheduleSyncOtherColumns(excluding: columnIndex); + } + } + } + + void _scheduleSyncOtherColumns({required int excluding}) { + if (_pendingSyncAllExclude != null && _pendingSyncAllExclude != excluding) { + // Source column changed within the same frame; the source column is + // already at the right pixels so any column is safe to exclude. + return; + } + _pendingSyncAllExclude = excluding; + if (_scrollSyncAllScheduled) return; + _scrollSyncAllScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollSyncAllScheduled = false; + if (!mounted || _isSynchronizingScrolls) return; + final exclude = _pendingSyncAllExclude; + _pendingSyncAllExclude = null; + if (exclude == null) return; + _syncOtherColumns(excluding: exclude); + }); + } + + void _syncOtherColumns({required int excluding}) { + _isSynchronizingScrolls = true; + // Sync ALL other columns, not just the adjacent one - this ensures e.g. + // Semi Finals and Final scroll with 8th Finals and Quarter Finals, so + // none of them are out of alignment by the time you page onto them. + for (var i = 0; i < _scrollControllers.length; i++) { + if (i == excluding) continue; + final controller = _scrollControllers[i]; + if (!controller.hasClients) continue; + final position = controller.position; + final target = _logicalScrollOffset.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((position.pixels - target).abs() > 0.5) { + controller.jumpTo(target); + } + } + _isSynchronizingScrolls = false; + } + + void _requestFocusWhenMounted( + _BracketMatchKey key, + int requestGeneration, [ + int attempt = 0, + ]) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || requestGeneration != _focusRequestGeneration) return; + final scope = _matchFocusScopes[key]; + if (scope == null || scope.context == null) { + if (attempt < 4) { + _requestFocusWhenMounted(key, requestGeneration, attempt + 1); + } + return; + } + + if (scope.hasFocus) { + _focusedMatch = key; + return; + } + + FocusNode? target; + for (final node in scope.descendants) { + if (node != scope && node.canRequestFocus && node.context != null) { + target = node; + } + } + (target ?? scope).requestFocus(); + _focusedMatch = key; + }); + } + + Future _focusMatch(int columnIndex, int requestedMatch) async { + // Ignore stale completions from repeated remote events. + final requestGeneration = ++_focusRequestGeneration; + final count = _matchCount(columnIndex); + if (count == 0) return; + final matchIndex = requestedMatch.clamp(0, count - 1); + _focusedMatch = _BracketMatchKey(columnIndex, matchIndex); + + // Capture the controller before awaiting anything so a concurrent data + // refresh (which rebuilds/resizes _scrollControllers) can't turn this + // stale request into an out-of-range or use-after-dispose crash. + if (columnIndex < 0 || columnIndex >= _scrollControllers.length) return; + final controller = _scrollControllers[columnIndex]; + Future? scrollAnimation; + if (controller.hasClients) { + final position = controller.position; + final desiredOffset = (matchIndex * _slotExtent(columnIndex) - + position.viewportDimension * 0.35) + .clamp(position.minScrollExtent, position.maxScrollExtent); + _logicalScrollOffset = desiredOffset; + if ((position.pixels - desiredOffset).abs() > 0.5) { + scrollAnimation = controller.animateTo( + desiredOffset, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + } + } + + // Focus must react to the key event, not wait for the viewport animation. + _requestFocusWhenMounted(_BracketMatchKey(columnIndex, matchIndex), + requestGeneration); + + // A card just outside the lazy cache may mount during this scroll. Retry + // once the latest request's animation completes in that case. + if (scrollAnimation != null) { + await scrollAnimation; + } + if (!mounted || requestGeneration != _focusRequestGeneration) return; + if (columnIndex < 0 || columnIndex >= widget.data.length) return; + _requestFocusWhenMounted( + _BracketMatchKey(columnIndex, matchIndex), requestGeneration); + } + + bool _moveVertically(int columnIndex, int matchIndex, int direction) { + // A horizontal transition is moving the page while its target layout state + // is being committed. A vertical move now would animate this column's + // ScrollController against changing geometry, so consume the event and + // keep focus anchored until the transition settles. + if (_isHorizontalTransitioning) return true; + final count = _matchCount(columnIndex); + if (count == 0) return false; + final activeMatch = _focusedMatch?.columnIndex == columnIndex + ? _focusedMatch!.matchIndex + : matchIndex; + final targetMatch = activeMatch + direction; + // Let the outer focus grid handle the column edges. + if (targetMatch < 0 || targetMatch >= count) { + return false; + } + + // Press-and-hold floods this handler with repeat events. Each one used to + // spin up a new scroll animation + focus-retry chain, pinning the isolate + // with allocation churn until the platform thread misses more than 5s of + // input and Android force-kills the app (ANR). Gate repeats: only one + // vertical move can be outstanding per animation window; intermediate + // repeats are consumed but do nothing. + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - _lastVerticalMoveAtMs < _verticalRepeatGateMs) { + return true; + } + _lastVerticalMoveAtMs = nowMs; + + // The column is a lazy list: only a window of cards is mounted. Moving + // focus onto a not-yet-mounted card scrolls the column and unmounts the + // currently focused card first, leaving no primary focus. The next repeat + // would then be picked up by the outer grid and escape the column. So only + // advance onto a registered card, and pre-scroll one slot to mount the next + // card for the following repeat. + if (!_matchFocusScopes + .containsKey(_BracketMatchKey(columnIndex, targetMatch))) { + _preScrollForVerticalMove(columnIndex, targetMatch); + return true; + } + + _focusMatch(columnIndex, targetMatch); + return true; + } + + /// Scrolls [columnIndex] toward [matchIndex] without moving focus, so the + /// lazy list mounts that card for the next vertical move. + void _preScrollForVerticalMove(int columnIndex, int matchIndex) { + if (columnIndex < 0 || columnIndex >= _scrollControllers.length) return; + final controller = _scrollControllers[columnIndex]; + if (!controller.hasClients) return; + final position = controller.position; + final slot = _slotExtent(columnIndex); + if (slot <= 0) return; + final desiredOffset = (matchIndex * slot - position.viewportDimension * 0.35) + .clamp(position.minScrollExtent, position.maxScrollExtent); + _logicalScrollOffset = desiredOffset; + if ((position.pixels - desiredOffset).abs() > 0.5) { + controller.animateTo( + desiredOffset, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + ); + } + } + @override void dispose() { for (var controller in _scrollControllers) { controller.dispose(); } + _layoutAnimationController + ..removeListener(_onLayoutAnimationTick) + ..dispose(); + _layoutStateNotifier.dispose(); + _pageScopeNode.dispose(); 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 && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (_isHorizontalTransitioning) return KeyEventResult.handled; + + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_activeColumnIndex < widget.data.length - 1) { + _startHorizontalTransition(_activeColumnIndex + 1); + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_activeColumnIndex > 0) { + _startHorizontalTransition(_activeColumnIndex - 1); + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + // A DOWN reaching the page scope means no match card consumed it (focus + // fell out of the lazy list while it was scrolling). Re-anchor inside the + // active column and consume the event so the outer focus grid cannot move + // focus into another bracket section. + final focused = _focusedMatch; + if (focused != null && focused.columnIndex == _activeColumnIndex) { + _moveVertically(focused.columnIndex, focused.matchIndex, 1); + } + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + /// If focus comes to rest on the page scope itself (because the focused card + /// unmounted during a scroll), immediately re-anchor it to the intended card + /// so the next D-pad event is handled by a match tile again. + void _onPageScopeFocusChange(bool hasFocus) { + if (!hasFocus || !_pageScopeNode.hasPrimaryFocus) return; + final focused = _focusedMatch; + if (focused == null || focused.columnIndex != _activeColumnIndex) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageScopeNode.hasPrimaryFocus) return; + _requestFocusWhenMounted(focused, _focusRequestGeneration); + }); + } + + /// Changes page and commits the target layout state. + void _startHorizontalTransition(int targetPage) { + final targetMatch = _focusedMatch?.columnIndex == _activeColumnIndex + ? _focusedMatch!.matchIndex + : 0; + + // Rapid section changes (e.g. holding D-pad arrows for tens of seconds) + // restart the layout and page transitions back to back, saturating the UI + // thread frame after frame until Android reports an ANR ("Input + // dispatching timed out") and force-kills the app. When transitions arrive + // faster than the previous transition could settle, snap instantly instead of + // animating; single transitions after an idle period still animate. + final nowMs = DateTime.now().millisecondsSinceEpoch; + final isFlooded = nowMs - _lastHorizontalTransitionAtMs < 650; + _lastHorizontalTransitionAtMs = nowMs; + _beginTransition(targetPage, targetMatch, snap: isFlooded); + } + + void _beginTransition(int targetPage, int targetMatch, + {required bool snap}) { + final transition = ++_transitionGeneration; + _isHorizontalTransitioning = true; + _activeColumnIndex = targetPage; + if (snap || !widget.pageController.hasClients) { + // A snap supersedes any in-flight reflow; stop it so its ticks cannot + // overwrite the committed stationary geometry. + _layoutAnimationController.stop(); + _snapPageTo(targetPage); + widget.onPrevColumnIndexChanged?.call(targetPage); + _layoutStateNotifier.value = _BracketLayoutState.stationary(targetPage); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || transition != _transitionGeneration) return; + _isHorizontalTransitioning = false; + _focusMatch(targetPage, targetMatch); + }); + return; + } + // Slide the page and reflow the column geometry at the same time. The two + // animations are orthogonal (horizontal page offset vs. vertical column + // spacing), so running them together reads as one motion. Committing the + // spacing only after the slide makes the incoming column visibly rearrange + // once it has already arrived (the "rapid expansion" jerk). Input floods + // still take the snap path above, which bounds the per-frame relayout + // churn that previously caused ANRs. + final pageSlide = widget.pageController + .animateToPage(targetPage, + duration: const Duration(milliseconds: 180), curve: Curves.easeOut) + .then((_) { + // A superseded transition must not move the selected tab/prevColumnIndex + // back to its own target once a newer one has started. A failed slide + // (caught by Future.wait below) must not advance the tab either. + if (!mounted || transition != _transitionGeneration) return; + // Update the selected tab after the slide so its rebuild stays out of + // the transition's critical frames. + widget.onPrevColumnIndexChanged?.call(targetPage); + }); + + // Release the transition lock and move focus only once BOTH the slide and + // the reflow have settled, so input stays anchored until the transition is + // fully finished. The reflow is given the same 180ms as the slide so focus + // is not held back by a longer standalone reflow. The generation guard + // keeps a superseded transition from clearing a newer one's lock. + Future.wait([ + pageSlide, + _animateLayoutTo(targetPage, duration: const Duration(milliseconds: 180)), + ]).then( + (_) { + if (!mounted || transition != _transitionGeneration) return; + _isHorizontalTransitioning = false; + _focusMatch(targetPage, targetMatch); + }, + onError: (Object _) { + // A failed slide/reflow must still release the transition lock so the + // bracket cannot be left permanently unresponsive. + if (!mounted || transition != _transitionGeneration) return; + _isHorizontalTransitioning = false; + }, + ); + } + + /// Snaps the page view to [targetPage] while staying inside the reachable + /// scroll range. + /// + /// [PageController.jumpToPage] force-sets pixels to + /// `page * viewportDimension * viewportFraction`. With `padEnds: false` and + /// `viewportFraction < 1` (the bracket's 0.4) those trailing pages map past + /// `maxScrollExtent` — the same clamp documented on `PageController.page` — + /// so the position is left out of range and the scroll physics springs it + /// back. That reads as a jerk/rapid throw exactly when the far rounds are + /// reached. Clamp the target instead so the snap is one stationary update. + void _snapPageTo(int targetPage) { + final controller = widget.pageController; + if (!controller.hasClients) return; + final position = controller.position; + final target = bracketSnapPageOffset( + page: targetPage, + viewportDimension: position.viewportDimension, + viewportFraction: controller.viewportFraction, + minScrollExtent: position.minScrollExtent, + maxScrollExtent: position.maxScrollExtent, + ); + if ((position.pixels - target).abs() > 0.5) { + position.jumpTo(target); + } + } + @override Widget build(BuildContext context) { - return NotificationListener( - onNotification: (CustomScrollNotification notification) { - int currentPage = widget.pageController.page?.round() ?? 0; - _scrollControllers[currentPage + 1].jumpTo(notification.scrollPosition); - return true; + Widget pageView = PageView.builder( + padEnds: false, + controller: widget.pageController, + physics: Device().isTV + ? const NeverScrollableScrollPhysics() + : const PageScrollPhysics(), + itemCount: widget.data.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, columnIndex) { + // _scrollControllers are resized in didUpdateWidget, which runs in the + // same frame; guard anyway so a momentarily stale count can't throw. + if (columnIndex < 0 || columnIndex >= _scrollControllers.length) { + return const SizedBox.shrink(); + } + return BracketsColumnPage( + controller: widget.controller, + roundData: widget.data[columnIndex], + columnIndex: columnIndex, + prevColumnIndex: widget.prevColumnIndex, + totalColumns: widget.data.length, + scrollController: _scrollControllers[columnIndex], + tvRowOffset: widget.tvRowOffset, + leftEdgePadding: widget.controller.leftEdgePadding, + rightEdgePadding: widget.controller.rightEdgePadding, + layoutState: _layoutStateNotifier, + onScrollNotification: _onColumnScroll, + registerMatchFocus: _registerMatchFocus, + unregisterMatchFocus: _unregisterMatchFocus, + onMoveVertical: _moveVertically, + onMatchFocused: (key) => _focusedMatch = key, + ); }, - child: PageView.builder( - padEnds: false, - controller: widget.pageController, - itemCount: widget.data.length, - onPageChanged: _onPageChanged, - itemBuilder: (context, columnIndex) { - return BracketsColumnPage( - controller: widget.controller, - roundData: widget.data[columnIndex], - columnIndex: columnIndex, - prevColumnIndex: _prevColumnIndex, - totalColumns: widget.data.length, - scrollController: _scrollControllers[columnIndex], - ); - }, - ), ); + + // On TV, wrap with FocusScope to catch delegated horizontal key events. + // Own the node so navigation can scope its search to this bracket's subtree. + if (Device().isTV) { + return FocusScope( + node: _pageScopeNode, + onKeyEvent: _handleKeyEvent, + onFocusChange: _onPageScopeFocusChange, + child: pageView, + ); + } + + return pageView; } } @@ -426,6 +1378,19 @@ class BracketsColumnPage extends StatefulWidget { final int totalColumns; final BracketController controller; final ScrollController scrollController; + final int tvRowOffset; + final double? leftEdgePadding; + final double? rightEdgePadding; + final ValueListenable<_BracketLayoutState> layoutState; + final void Function(int columnIndex, ScrollNotification notification)? + onScrollNotification; + final void Function(_BracketMatchKey key, FocusScopeNode node)? + registerMatchFocus; + final void Function(_BracketMatchKey key, FocusScopeNode node)? + unregisterMatchFocus; + final bool Function(int columnIndex, int matchIndex, int direction)? + onMoveVertical; + final ValueChanged<_BracketMatchKey>? onMatchFocused; const BracketsColumnPage({ super.key, @@ -435,6 +1400,15 @@ class BracketsColumnPage extends StatefulWidget { required this.totalColumns, required this.controller, required this.scrollController, + required this.layoutState, + this.tvRowOffset = 0, + this.leftEdgePadding, + this.rightEdgePadding, + this.onScrollNotification, + this.registerMatchFocus, + this.unregisterMatchFocus, + this.onMoveVertical, + this.onMatchFocused, }); @override @@ -444,95 +1418,377 @@ class BracketsColumnPage extends StatefulWidget { class _BracketsColumnPageState extends State { late double matchCardHeight; List computedMatches = []; + late SliverChildBuilderDelegate _sliverDelegate; @override void initState() { computedMatches = widget.roundData.localScope.dataContext .eval(widget.roundData.matches.data); matchCardHeight = widget.roundData.matches.height; + _sliverDelegate = _createSliverDelegate(); super.initState(); } + @override + void didUpdateWidget(covariant BracketsColumnPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(widget.roundData, oldWidget.roundData)) { + computedMatches = widget.roundData.localScope.dataContext + .eval(widget.roundData.matches.data); + matchCardHeight = widget.roundData.matches.height; + _sliverDelegate = _createSliverDelegate(); + } + } + + SliverChildBuilderDelegate _createSliverDelegate() => + SliverChildBuilderDelegate( + (context, matchIndex) => _buildMatch(context, matchIndex), + childCount: computedMatches.length, + addAutomaticKeepAlives: false, + addRepaintBoundaries: true, + ); + + Widget _buildMatch(BuildContext context, int matchIndex) { + final matchData = computedMatches[matchIndex]; + 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); + + final widgetModel = matchScope + .buildWidgetModelFromDefinition(widget.roundData.matches.template); + Widget matchWidget = DataScopeWidget( + scopeManager: matchScope, + child: matchScope.buildWidgetFromModel(widgetModel), + ); + + final key = _BracketMatchKey(widget.columnIndex, matchIndex); + if (Device().isTV && widget.registerMatchFocus != null) { + matchWidget = _BracketMatchFocusTarget( + key: ValueKey(key), + matchKey: key, + onMoveVertical: widget.onMoveVertical, + onFocused: widget.onMatchFocused, + focusOrder: TVFocusOrder.withOptions( + (widget.tvRowOffset + 1 + matchIndex).toDouble(), + order: widget.columnIndex.toDouble(), + isRowEntryPoint: + matchIndex == 0 && widget.columnIndex == widget.prevColumnIndex, + delegateHorizontalNavigation: true, + ), + register: widget.registerMatchFocus!, + unregister: widget.unregisterMatchFocus!, + child: matchWidget, + ); + } else if (Device().isTV) { + // Keep the historic TV traversal when this column is used without the + // BracketsPage coordinator. + matchWidget = TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + (widget.tvRowOffset + 1 + matchIndex).toDouble(), + order: widget.columnIndex.toDouble(), + isRowEntryPoint: matchIndex == 0, + delegateHorizontalNavigation: true, + ), + child: matchWidget, + ); + } + + return ValueListenableBuilder<_BracketLayoutState>( + valueListenable: widget.layoutState, + child: matchWidget, + builder: (context, layout, card) => Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: matchCardHeight, + width: MediaQuery.of(context).size.width * + widget.controller.matchCardWidthFraction, + child: Container( + margin: EdgeInsets.only( + left: widget.controller.connectorLength * + layout.leftConnectorProgressFor(widget.columnIndex), + ), + child: CustomPaint( + painter: BracketPainter( + isTopBracket: widget.columnIndex + 1 == widget.totalColumns + ? null + : !(matchIndex.isEven), + showLeftBorder: + layout.leftConnectorProgressFor(widget.columnIndex) > 0, + leftBorderOpacity: + layout.leftConnectorProgressFor(widget.columnIndex), + lineColor: widget.controller.lineColor ?? Colors.black, + borderColor: widget.controller.borderColor ?? Colors.black, + lineWidth: widget.controller.lineWidth ?? 2.0, + borderWidth: widget.controller.borderWidth ?? 2.0, + connectorLength: widget.controller.connectorLength, + columnIndex: widget.columnIndex, + prevColumnIndex: layout.toColumnIndex, + spacingMultiplier: layout.multiplierFor(widget.columnIndex), + ), + child: card, + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { - bool isNextColumn = widget.columnIndex == widget.prevColumnIndex + 1; - - return NotificationListener( - onNotification: (ScrollNotification notification) { - if (notification is ScrollUpdateNotification) { - double visibleHeight = notification.metrics.viewportDimension; - CustomScrollNotification( - notification.metrics.pixels, - notification.metrics.maxScrollExtent, - visibleHeight, - ).dispatch(context); - } - return true; - }, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - physics: const ClampingScrollPhysics(), - controller: widget.scrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...computedMatches.asMap().entries.map((entry) { - final matchIndex = entry.key; - final matchData = entry.value; - - double topOffset = 0; - if (widget.prevColumnIndex < widget.columnIndex) { - topOffset = topOffset + (matchCardHeight / 2); - if (matchIndex > 0) { - topOffset = topOffset + matchCardHeight / 2; - } - } - - widget.roundData.localScope.dataContext - .addDataContextById(widget.roundData.matches.name, matchData); - - final cellWidget = widget.roundData.localScope - .buildWidgetFromDefinition(widget.roundData.matches.template); - return AnimatedContainer( - height: matchCardHeight, - width: MediaQuery.of(context).size.width * 0.6, - duration: const Duration(milliseconds: 300), - margin: EdgeInsets.only( - top: topOffset, left: !isNextColumn ? 0 : 15), - child: CustomPaint( - painter: BracketPainter( - isTopBracket: widget.columnIndex + 1 == widget.totalColumns - ? null - : !(matchIndex % 2 == 0), - 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, - ), - child: Padding( - padding: const EdgeInsets.all(2.0), - child: cellWidget, + // TV: reuse the cached delegate across navigation rebuilds so a + // prevColumnIndex change doesn't discard the sliver's built-child cache + // (re-parsing/instantiating every visible tile on every navigation). + // Mobile builds fresh each frame (unchanged). + final sliverDelegate = + Device().isTV ? _sliverDelegate : _createSliverDelegate(); + + final double leftPad = widget.leftEdgePadding ?? 0.0; + final double rightPad = widget.columnIndex == widget.totalColumns - 1 + ? (widget.rightEdgePadding ?? 0.0) + : 0.0; + + return Padding( + padding: EdgeInsets.only(left: leftPad, right: rightPad), + child: ValueListenableBuilder<_BracketLayoutState>( + valueListenable: widget.layoutState, + builder: (context, layout, _) { + final multiplier = layout.multiplierFor(widget.columnIndex); + final slotExtent = matchCardHeight * multiplier; + final cardTopOffset = matchCardHeight * (multiplier - 1) / 2; + return NotificationListener( + onNotification: (notification) { + widget.onScrollNotification + ?.call(widget.columnIndex, notification); + return false; + }, + child: CustomScrollView( + controller: widget.scrollController, + physics: const ClampingScrollPhysics(), + cacheExtent: matchCardHeight * 2, + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + top: cardTopOffset, bottom: cardTopOffset), + sliver: SliverFixedExtentList( + itemExtent: slotExtent, + delegate: sliverDelegate, ), ), - ); - }), - if (isNextColumn) SizedBox(height: matchCardHeight), - ], - ), + ], + ), + ); + }, ), ); } } +/// Interpolates bracket-tree geometry during mobile layout transitions. +class _BracketLayoutState { + const _BracketLayoutState({ + required this.fromColumnIndex, + required this.toColumnIndex, + required this.progress, + }); + + factory _BracketLayoutState.stationary(int columnIndex) => + _BracketLayoutState( + fromColumnIndex: columnIndex, + toColumnIndex: columnIndex, + progress: 1, + ); + + final int fromColumnIndex; + final int toColumnIndex; + final double progress; + + _BracketLayoutState copyWith({double? progress}) => _BracketLayoutState( + fromColumnIndex: fromColumnIndex, + toColumnIndex: toColumnIndex, + progress: progress ?? this.progress, + ); + + double multiplierFor(int columnIndex) { + double multiplierForAnchor(int anchor) { + final distance = columnIndex - anchor; + return distance > 0 ? (1 << distance).toDouble() : 1; + } + + final from = multiplierForAnchor(fromColumnIndex); + final to = multiplierForAnchor(toColumnIndex); + return from + (to - from) * progress; + } + + double leftConnectorProgressFor(int columnIndex) { + final from = fromColumnIndex < columnIndex ? 1.0 : 0.0; + final to = toColumnIndex < columnIndex ? 1.0 : 0.0; + return from + (to - from) * progress; + } +} + +/// Scroll offset that positions [page] in a [PageController] viewport while +/// staying inside the reachable `[minScrollExtent, maxScrollExtent]` range. +/// +/// Mirrors `_PagePosition.getPixelsFromPage` (`page * viewportDimension * +/// viewportFraction` plus the viewportFraction > 1 centering offset), then +/// clamps. This is what keeps a `padEnds: false` / fractional `PageView` from +/// being force-scrolled past its last page and sprung back. +@visibleForTesting +double bracketSnapPageOffset({ + required int page, + required double viewportDimension, + required double viewportFraction, + required double minScrollExtent, + required double maxScrollExtent, +}) { + final initialOffset = viewportFraction > 1 + ? viewportDimension * (viewportFraction - 1) / 2 + : 0.0; + return (page * viewportDimension * viewportFraction + initialOffset) + .clamp(minScrollExtent, maxScrollExtent) + .toDouble(); +} + +class _BracketMatchKey { + const _BracketMatchKey(this.columnIndex, this.matchIndex); + + final int columnIndex; + final int matchIndex; + + @override + bool operator ==(Object other) => + other is _BracketMatchKey && + other.columnIndex == columnIndex && + other.matchIndex == matchIndex; + + @override + int get hashCode => Object.hash(columnIndex, matchIndex); +} + +/// Lightweight per-card TV focus boundary. +class _BracketMatchFocusTarget extends StatefulWidget { + const _BracketMatchFocusTarget({ + super.key, + required this.matchKey, + required this.register, + required this.unregister, + required this.child, + required this.focusOrder, + this.onMoveVertical, + this.onFocused, + }); + + final _BracketMatchKey matchKey; + final void Function(_BracketMatchKey key, FocusScopeNode node) register; + final void Function(_BracketMatchKey key, FocusScopeNode node) unregister; + final TVFocusOrder focusOrder; + final bool Function(int columnIndex, int matchIndex, int direction)? + onMoveVertical; + final ValueChanged<_BracketMatchKey>? onFocused; + final Widget child; + + @override + State<_BracketMatchFocusTarget> createState() => + _BracketMatchFocusTargetState(); +} + +class _BracketMatchFocusTargetState extends State<_BracketMatchFocusTarget> { + late final FocusScopeNode _scopeNode = FocusScopeNode( + debugLabel: + 'BracketMatch(${widget.matchKey.columnIndex}, ${widget.matchKey.matchIndex})', + ); + + @override + void initState() { + super.initState(); + widget.register(widget.matchKey, _scopeNode); + } + + @override + void dispose() { + widget.unregister(widget.matchKey, _scopeNode); + _scopeNode.dispose(); + super.dispose(); + } + + KeyEventResult _onKeyEvent(FocusNode _, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final didMove = widget.onMoveVertical + ?.call(widget.matchKey.columnIndex, widget.matchKey.matchIndex, -1); + return didMove == true ? KeyEventResult.handled : KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + widget.onMoveVertical + ?.call(widget.matchKey.columnIndex, widget.matchKey.matchIndex, 1); + // At the final card, keep focus in this column. Letting DOWN bubble into + // the outer focus grid can select a card in a different-length column. + return KeyEventResult.handled; + } + // LEFT/RIGHT deliberately bubble to BracketsPage's FocusScope. + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) => TVFocusWidget( + focusOrder: widget.focusOrder, + primaryFocusNode: _scopeNode, + child: FocusScope( + node: _scopeNode, + onKeyEvent: _onKeyEvent, + onFocusChange: (hasFocus) { + if (!hasFocus) return; + widget.onFocused?.call(widget.matchKey); + if (_scopeNode.hasPrimaryFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scopeNode.hasPrimaryFocus) return; + for (final node in _scopeNode.descendants) { + if (node != _scopeNode && + node.canRequestFocus && + node.context != null) { + node.requestFocus(); + } + } + }); + } + }, + child: widget.child, + ), + ); +} + +/// 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. +/// [spacingMultiplier] - Live interpolated spacing from [_BracketLayoutState], +/// used during a column transition; falls back to [prevColumnIndex] when unset. +/// 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; + final double? spacingMultiplier; + final double leftBorderOpacity; BracketPainter({ this.isTopBracket, @@ -541,6 +1797,11 @@ 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, + this.spacingMultiplier, + this.leftBorderOpacity = 1, }); @override @@ -560,25 +1821,152 @@ 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 (spacingMultiplier != null) { + // Live-interpolated spacing during a column transition. + verticalLength = cardHeight * 0.5 * spacingMultiplier!; + } else 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); canvas.drawLine(verticalStartPoint, verticalEndPoint, linePaint); } - if (showLeftBorder) { + if (showLeftBorder && leftBorderOpacity > 0) { final leftStartPoint = Offset(0, size.height / 2); - final leftEndPoint = Offset(-25, size.height / 2); - canvas.drawLine(leftStartPoint, leftEndPoint, linePaint); + final leftEndPoint = Offset(-connectorLength, size.height / 2); + final leftLinePaint = Paint() + ..color = lineColor.withValues(alpha: leftBorderOpacity) + ..strokeWidth = lineWidth + ..style = PaintingStyle.stroke; + canvas.drawLine(leftStartPoint, leftEndPoint, leftLinePaint); } } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; + bool shouldRepaint(covariant BracketPainter oldDelegate) => + oldDelegate.columnIndex != columnIndex || + oldDelegate.prevColumnIndex != prevColumnIndex || + oldDelegate.spacingMultiplier != spacingMultiplier || + oldDelegate.leftBorderOpacity != leftBorderOpacity || + oldDelegate.isTopBracket != isTopBracket || + oldDelegate.showLeftBorder != showLeftBorder || + oldDelegate.connectorLength != connectorLength || + oldDelegate.lineColor != lineColor || + oldDelegate.lineWidth != lineWidth || + oldDelegate.borderColor != borderColor || + oldDelegate.borderWidth != borderWidth; +} + +/// TV focus provider for the Bracket widget. +/// +/// 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, + bool rememberRowPosition = false, + 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, + ); } + + // Bracket handles its own TV navigation and has no row-position memory. + @override + void saveRowPosition({ + required ModalRoute? route, + required double row, + required double order, + String? focusGroup, + }) {} } diff --git a/modules/bracket/test/ensemble_bracket_test.dart b/modules/bracket/test/ensemble_bracket_test.dart index 0da434d92..5595b4ecb 100644 --- a/modules/bracket/test/ensemble_bracket_test.dart +++ b/modules/bracket/test/ensemble_bracket_test.dart @@ -1,5 +1,81 @@ +import 'package:ensemble_bracket/ensemble_bracket.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('adds one to input values', () {}); + testWidgets('PageController recreation with no rounds is safe', + (tester) async { + final initialController = BracketController(); + final updatedController = BracketController(); + updatedController.setters()['scale']?.call(0.5); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: BracketsView(data: const [], controller: initialController), + ), + )); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: BracketsView(data: const [], controller: updatedController), + ), + )); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + expect(tester.takeException(), isNull); + }); + + test('bracketSnapPageOffset clamps trailing pages to reachable range', () { + // 7 rounds at viewportFraction 0.4 in a 960pt viewport: content is 2688pt, + // so maxScrollExtent is 1728. Page 6 maps to 2304 unclamped and page 5 to + // 1920; both must clamp, or jumpTo() springs back (the "rapid throw"). + const viewport = 960.0; + const fraction = 0.4; + const max = 1728.0; + + expect( + bracketSnapPageOffset( + page: 6, + viewportDimension: viewport, + viewportFraction: fraction, + minScrollExtent: 0, + maxScrollExtent: max, + ), + max, + ); + expect( + bracketSnapPageOffset( + page: 5, + viewportDimension: viewport, + viewportFraction: fraction, + minScrollExtent: 0, + maxScrollExtent: max, + ), + max, + ); + // Reachable pages keep their exact offset. + expect( + bracketSnapPageOffset( + page: 4, + viewportDimension: viewport, + viewportFraction: fraction, + minScrollExtent: 0, + maxScrollExtent: max, + ), + 1536, + ); + // viewportFraction > 1 keeps the centering initial offset. + expect( + bracketSnapPageOffset( + page: 0, + viewportDimension: 100, + viewportFraction: 2, + minScrollExtent: 0, + maxScrollExtent: 1000, + ), + 50, + ); + }); } diff --git a/modules/ensemble/assets/schema/ensemble_schema.json b/modules/ensemble/assets/schema/ensemble_schema.json index 2f2927182..a4dfa3eb5 100644 --- a/modules/ensemble/assets/schema/ensemble_schema.json +++ b/modules/ensemble/assets/schema/ensemble_schema.json @@ -67,11 +67,24 @@ }, "flexibleMinHeight": { "type": "integer", - "description": "Applicable only if scrollableView is enabled. This attribute effectively sets the header's min height on scrolling (header's height will varies between the flexibleMinHeight and flexibleMaxHeight). Note that this attribute will be ignored if smaller than the titleBarHeight" + "description": "Sets the header's compact height for scrollable headers and TV focus-collapsible fixed headers. It is ignored when smaller than titleBarHeight." }, "flexibleMaxHeight": { "type": "integer", - "description": "Applicable only if scrollableView is enabled. This attribute effectively sets the header's max height on scrolling (header's height will varies between the flexibleMinHeight and flexibleMaxHeight). This attribute will be ignored if smaller than the flexibleMinHeight" + "description": "Sets the header's expanded height for scrollable headers and TV focus-collapsible fixed headers. It is ignored when smaller than flexibleMinHeight." + }, + "collapsibleHeader": { + "type": "object", + "description": "Controls fixed-header collapse. When visible is set, it shows or hides the header. On TV, an enabled fixed header without visible expands while focus is in the header and collapses when focus moves to the body. TV focus collapse requires a focusable titleWidget or leadingWidget as the compact-header re-entry target.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable collapsible header behavior. Default false." + }, + "visible": { + "description": "Expression that shows or hides a fixed header." + } + } } } } @@ -158,6 +171,10 @@ }, { "properties": { + "background": { + "$ref": "#/$defs/Widget", + "description": "TV only. Optional widget rendered as the page's bottom-most background layer, behind the body. When set, backgroundColor, backgroundImage, and backgroundGradient are ignored; add any required color or image inside this widget. The background never takes TV D-pad focus and does not receive taps. Note: a childless Column/Row with only a background is not rendered; add a Spacer child (the app's convention for background-only layers)." + }, "useSafeArea": { "type": "boolean", "description": "Applicable only when we don't have a header. If true, insert paddings around the body content to account for the the devices' Safe Area (e.g. iPhone notch). Default is false." @@ -2057,7 +2074,7 @@ "$ref": "#/$defs/type-color" }, "dropdownBorderWidth": { - "type": "integer", + "type": "number", "minimum": 0, "description": "The thickness of the dropdown border" }, @@ -3253,6 +3270,14 @@ "children": { "$ref": "#/$defs/Widgets" }, + "direction": { + "type": "string", + "description": "The direction in which the list scrolls. Default is vertical.", + "enum": [ + "vertical", + "horizontal" + ] + }, "onItemTap": { "description": "Dispatch when an ListView item is selected/tapped.The event dispatches only when you tap on the item. The index of the item can be retrieved using 'selectedItemIndex'.", "$ref": "#/$defs/Action-payload" @@ -3322,6 +3347,10 @@ "reverse": { "type": "boolean", "description": "Show list in reverse order. Default is false" + }, + "minHeight": { + "type": "integer", + "description": "Minimum height of the list" } } } @@ -3460,6 +3489,14 @@ "indicatorPadding": { "$ref": "#/$defs/Padding-payload", "description": "The padding around the indicator" + }, + "indicatorLeadingWidget": { + "type": "object", + "description": "Widget displayed before the indicator row or column" + }, + "indicatorTrailingWidget": { + "type": "object", + "description": "Widget displayed after the indicator row or column" } } } @@ -3590,12 +3627,19 @@ "description": "The fill color for this input fields. This property can be defined in the theme to apply to all Input widgets." }, "borderRadius": { - "type": "integer", - "minimum": 0, + "oneOf": [ + { + "type": "string" + }, + { + "type": "number", + "minimum": 0 + } + ], "description": "The border radius for this Input widget. This property can be defined in the theme to apply to all Input widgets." }, "borderWidth": { - "type": "integer", + "type": "number", "minimum": 0, "description": "The border width for this Input widget. This property can be defined in the theme to apply to all Input widgets." }, @@ -5565,7 +5609,7 @@ "$ref": "#/$defs/type-color" }, "borderWidth": { - "type": "integer" + "type": "number" }, "itemDisplay": { "type": "string", @@ -6771,6 +6815,10 @@ "$ref": "#/$defs/type-color", "description": "The color of the tabbar divider color" }, + "overlayIndicator": { + "type": "boolean", + "description": "Whether the indicator overlaps the divider" + }, "inactiveTabColor": { "$ref": "#/$defs/type-color", "description": "The color of the un-selected tabs' text" @@ -6786,6 +6834,10 @@ "indicatorThickness": { "type": "integer", "description": "The thickness of the selected tab's indicator" + }, + "revealTabBarOnFocus": { + "type": "boolean", + "description": "Reveal the whole TabBar when a tab receives focus" } } }, @@ -7316,12 +7368,12 @@ "type": "string" }, { - "title": "integer", - "type": "integer" + "title": "number", + "type": "number" } ], "minimum": 0, - "description": "Border Radius with CSS-like notation (1 to 4 integers)" + "description": "Border Radius with CSS-like notation (1 to 4 numbers)" } } }, @@ -7841,7 +7893,7 @@ "description": "Border color, starting with '0xFF' for full opacity" }, "borderWidth": { - "type": "integer", + "type": "number", "minimum": 0, "description": "The thickness of the border" } @@ -8190,4 +8242,4 @@ } } } -} \ No newline at end of file +} diff --git a/modules/ensemble/assets/schema/theme_schema.json b/modules/ensemble/assets/schema/theme_schema.json index 3cb627311..290330276 100644 --- a/modules/ensemble/assets/schema/theme_schema.json +++ b/modules/ensemble/assets/schema/theme_schema.json @@ -199,12 +199,11 @@ "description": "The fill color for applicable input fields (TextInput, Dropdown, ...)" }, "borderRadius": { - "type": "integer", - "minimum": 0, + "$ref": "#/$defs/BorderRadius", "description": "The border radius for applicable Input widgets." }, "borderWidth": { - "type": "integer", + "type": "number", "minimum": 0, "description": "The border width for applicable Input widgets." }, @@ -237,11 +236,10 @@ "type": "object", "properties": { "borderRadius": { - "type": "integer", - "minimum": 0 + "$ref": "#/$defs/BorderRadius" }, "borderWidth": { - "type": "integer", + "type": "number", "minimum": 0, "description": "The border thickness of the button" }, @@ -275,7 +273,7 @@ "description": "The border radius for this Input widget. This property can be defined in the theme to apply to all Input widgets." }, "borderWidth": { - "type": "integer", + "type": "number", "description": "The border width for this Input widget. This property can be defined in the theme to apply to all Input widgets.", "minimum": 0 }, @@ -414,11 +412,11 @@ "type": "string" } ], - "description": "Border Radius with CSS-like notation (1 to 4 integers)", + "description": "Border Radius with CSS-like notation (1 to 4 numbers)", "uiType": "borderRadius" }, "positiveInteger": { - "type": "integer", + "type": "number", "minimum": 0 }, "type-fontWeight": { diff --git a/modules/ensemble/doc/layout-widgets.md b/modules/ensemble/doc/layout-widgets.md index 6ef92e140..0cb44fe40 100644 --- a/modules/ensemble/doc/layout-widgets.md +++ b/modules/ensemble/doc/layout-widgets.md @@ -21,6 +21,10 @@ renders only the tab navigation. Both are backed by `BaseTabBar`, | `navigateTo(index)` | Programmatically selects a tab. `changeTabItem(index)` is kept as a legacy alias. | | `useIndexedTab` | Builds each tab body on first visit and caches it for later tab switches. | | `persistentTabBar` | Wraps tab bodies in a scroll view so the tab bar remains pinned while body content scrolls. | +| `styles.tabFocusOnBack` | TV-only, opt-in: the first Back press from tab content restores focus to the selected tab; a separate next press uses normal navigation. Defaults to `false`. | + +`tabFocusOnBack` applies to `TabBar` content; `TabBarOnly` has no body to return +from, and `ScrollableTabBar` retains its scroll-driven selection behavior. ### Example @@ -88,6 +92,7 @@ the widget id. | `scrollToTop()` / `scrollToBottom()` | Convenience methods for the start and current maximum scroll extent. | | `scrollToIndex(index)` | Estimates the offset from the templated data length and clamps the index into range. | | `onScroll` | Receives the current pixel offset in `event.data.pixel`. | +| `cacheExtent` | Pixel distance (before and after the viewport) in which children are built. ### Example diff --git a/modules/ensemble/doc/tv_developer_guide.md b/modules/ensemble/doc/tv_developer_guide.md new file mode 100644 index 000000000..437534c8a --- /dev/null +++ b/modules/ensemble/doc/tv_developer_guide.md @@ -0,0 +1,1410 @@ +# Ensemble TV Complete Guide + +Comprehensive documentation for TV/D-pad navigation in the Ensemble framework, including YAML patterns, framework architecture, and host app 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](#7-host-app-integration) +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 │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ TV Top Navigation Bar │ │ +│ │ [Home] [Live] [Movies] [Series] [Category] [Apps] │ │ +│ │ ↑ Order 5 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Category Tab Content │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ TVFocusProviderScope │ │ │ +│ │ │ ┌───────────────────────────────────────────────┐ │ │ │ +│ │ │ │ MyFocusProvider │ │ │ │ +│ │ │ │ - rowOffset: 1.0 (below tab bar) │ │ │ │ +│ │ │ │ - orderOffset: 5.0 (Category tab align) │ │ │ │ +│ │ │ └───────────────────────────────────────────────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌───────────────────────────────────────────────┐ │ │ │ +│ │ │ │ EnsembleScreenRenderer │ │ │ │ +│ │ │ │ - Renders Ensemble YAML UI definitions │ │ │ │ +│ │ │ │ - Widgets wrapped with TVFocusWidget │ │ │ │ +│ │ │ └───────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Focus Grid Alignment + +``` +Host App Tab Bar (Row 0): + [Home:0] [Live:1] [Movies:2] [Series:3] [Watchlist:4] [Category:5] [Apps:6] + +Ensemble Content (Row 1+, aligned with Category tab at order 5): + [Item1:5] [Item2:6] [Item3:7] [Item4:8] + +↑ UP from Ensemble → Category tab +↓ DOWN from Category 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 + rememberRowPosition: true # Remember the column when re-entering this row (inheritable) + + # 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) + scrollMode: keepVisible # 'legacy' (default) or 'keepVisible' minimal reveal + fixedFocusScroll: true # Enable Netflix-style scrolling (legacy only) + fixedFocusOffset: 48 # Offset from left edge (pixels) (legacy only) + verticalScrollPadding: 100 # Extra padding when scrolling vertically (legacy only) + scrollAnimationDuration: 200 # Scroll animation duration (ms) + scrollAnimationCurve: easeOut # Animation curve + horizontalScrollPadding: 16 # Horizontal padding for visibility checks (legacy only) + + # 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. | +| **rememberRowPosition** | `bool` | `false` | When `true`, the row remembers which column (order) you were on and restores it when you return. **Inheritable** — set once on a container and all descendants inherit it. Scoped per screen; independent of `focusGroup`. See [Row-Position Memory](#row-position-memory-rememberrowposition). | +| **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. | +| **scrollMode** | `String` | `legacy` | Auto-scroll strategy: `legacy` or `keepVisible` (see below). | + +##### `scrollMode: keepVisible` + +`legacy` (default) keeps the existing behavior: manual, animated scrolling that centers +horizontal items (or pins them to `fixedFocusOffset`) and applies `verticalScrollPadding`. + +`keepVisible` reveals the focused item with **minimal movement** using each scrollable's own +`ScrollPosition.ensureVisible` with `keepVisibleAtStart`/`End`, like Flutter's default focus +traversal. It is correct for slivers, pinned/overlay headers, content padding and nested +scrollables, and it only scrolls when the item is actually offscreen. + +```yaml +Row: + styles: + tvOptions: + row: 3 + scrollMode: keepVisible # minimal, geometry-aware reveal +``` + +When `scrollMode: keepVisible` is set, `fixedFocusScroll`, `fixedFocusOffset`, +`verticalScrollPadding` and `horizontalScrollPadding` are ignored — they only apply to +`legacy`. `scrollAnimationDuration`, `scrollAnimationCurve` and `resetScrollOnFocus` still +apply. Prefer `keepVisible` for new TV layouts; `legacy` remains the default for backward +compatibility. + + +#### 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). | +| **focusHighlightColor** | `Color` | theme `focusColor` | Overlay painted behind the scrollbar while it has focus. Use `transparent` to hide it. | +| **placement** | `String` | `'inside'` | `'inside'`: scrollbar inside the ListView's box (current behavior). `'outside'`: beside the box (background, border, clip), so content can use the full box width. Needs a bounded width and height. Unknown values fall back to `'inside'`. | +| **gap** | `number` | `0` | Space between the box and the scrollbar when `placement` is `'outside'`. | + +### Row-Position Memory (`rememberRowPosition`) + +By default, focus does **not** remember your horizontal position. Leave a row and come back, and focus +lands on the row's entry point (same column / clamped), not where you last were. + +Set **`rememberRowPosition: true`** to make a region remember the column (order) you were on and restore +it when you return to that row. + +**Key facts:** + +- **Default is `false`.** Grids that should navigate purely by column (e.g. an on-screen keyboard) must + leave it unset — turning it on would make UP/DOWN jump to a remembered column instead of the one + directly above/below. +- **Inheritable.** Declare it once on a container and every focusable descendant inherits it — you do + **not** repeat it per widget. It inherits like `focusGroup` and `edges`. +- **Independent of `focusGroup`.** Memory is scoped per **screen (route)** automatically, so it works + with or without `focusGroup`. Use `focusGroup` only for genuine intra-screen isolation between two + regions that share row numbers. +- **Scope & lifecycle.** Keyed by the current route, so one screen's memory never leaks into another, + and it is freed when the screen leaves the navigation stack. Memory stays current on every entry path + (D-pad, taps, edge jumps, carousel restore), not just vertical D-pad moves. + +**Where to put it:** prefer the row itself (the container holding one lane's items). Because it inherits, +you can also put it on a parent wrapping several rows and each row underneath remembers its own column. + +**Recommended — on the row itself (one lane):** + +```yaml +Row: + styles: + scrollable: true + tvOptions: + rememberRowPosition: true # inherited by every card in this lane + item-template: + data: ${movies} + name: movie + indexId: idx + template: + MediaCard: + inputs: + tvOptions: + row: 2 + order: ${idx} + isRowEntryPoint: ${idx == 0} +``` + +**Also valid — on a parent wrapping multiple rows:** + +```yaml +Column: + styles: + tvOptions: + rememberRowPosition: true # inherited by row 2 AND row 3 + children: + - Row: # lane 1 (row: 2) — remembers its column + styles: { scrollable: true } + item-template: { data: ${movies}, ... } + - Row: # lane 2 (row: 3) — remembers its column independently + styles: { scrollable: true } + item-template: { data: ${series}, ... } +``` + +**When to use it:** horizontal content lanes (Netflix rows) where the user scrolls right, moves to +another lane, then returns expecting to be where they left off. + +**When NOT to use it:** 2D grids where the column directly above/below is expected (on-screen keyboards, +calculator pads). Leave it `false` there. + +### 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 +- **Outside the box**: With `placement: outside` the scrollbar sits next to the ListView's background and rounded corners, so focused items can fill the full width. Its slot keeps `focusedWidth`, so the box does not resize when the scrollbar gains focus: + +```yaml +ListView: + styles: + backgroundColor: 0xFF232323 + borderRadius: 9 + tvOptions: + scrollbarOptions: + placement: outside + gap: 2 + focusHighlightColor: transparent +``` + +--- + +## 7. Host App Integration + +### 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(); +} +``` + +### Example Host App Implementation + +```dart +class MyFocusProvider implements TVFocusProvider { + @override + double get rowOffset => 1.0; // Below tab bar + + @override + double get orderOffset => 5.0; // Aligned with the active tab + + @override + Color? get focusBorderColor => AppTheme.current.focusColor; + + @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 MyFocusWidget( + focusOrder: MyFocusOrder.withOptions( + row, + order: order, + isRowEntryPoint: isRowEntryPoint, + lockHorizontalNavigation: lockHorizontalNavigation, + delegateHorizontalNavigation: delegateHorizontalNavigation, + disableHostScroll: disableHostScroll, + ), + goBackButtonHandled: onBackPressed, + disableHostScroll: disableHostScroll, + child: child, + ); + } +} +``` + +### Integration with EnsembleApp + +```dart +// In the host app's tab content +EnsembleWrapper( + tvFocusProvider: MyFocusProvider(), + child: EnsembleScreen(payload: payload), +) + +// Internally wraps with scope: +if (widget.tvFocusProvider != null) { + app = TVFocusProviderScope( + provider: widget.tvFocusProvider!, + child: app, + ); +} +``` + +### Integration Checklist + +When wiring `TVFocusProvider` into a host app, expect to touch: + +- A provider implementation (bridges the host app's own focus system to Ensemble's `TVFocusProvider` interface). +- The host's focusable button/wrapper widget (implements `delegateHorizontalNavigation` so carousels and rows can hand LEFT/RIGHT back to the host). +- The integration point where `EnsembleWrapper`/`EnsembleScreen` is embedded (passes the provider in and wraps content with `TVFocusProviderScope`). +- The TV-specific screen/route where Ensemble content is rendered (sets up the focus scope for the embedded content). + +--- + +## 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) + ↓ (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 +- [ ] `rememberRowPosition` lanes restore the last column on return; grids/keyboards (unset) still navigate by column + +### 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/integration_test/local/themedApp/Widgets.yaml b/modules/ensemble/integration_test/local/themedApp/Widgets.yaml index bf6a8d7cc..7b50b3af1 100644 --- a/modules/ensemble/integration_test/local/themedApp/Widgets.yaml +++ b/modules/ensemble/integration_test/local/themedApp/Widgets.yaml @@ -10,8 +10,8 @@ View: - TextInput: value: updated style styles: - borderRadius: 10 - borderWidth: 2 + borderRadius: 10.5 + borderWidth: 2.5 borderColor: 0xFFCFA17B focusedBorderColor: 0xFF86BAA6 @@ -19,4 +19,5 @@ View: value: box style styles: variant: box - borderRadius: 99 + borderRadius: 99.5 + borderWidth: 0.5 diff --git a/modules/ensemble/integration_test/local/themedApp/theme.ensemble b/modules/ensemble/integration_test/local/themedApp/theme.ensemble index 64730827e..db087279f 100644 --- a/modules/ensemble/integration_test/local/themedApp/theme.ensemble +++ b/modules/ensemble/integration_test/local/themedApp/theme.ensemble @@ -1,6 +1,7 @@ Widgets: Input: # default variant is Underline - borderRadius: 5 + borderRadius: 5.5 + borderWidth: 1.5 borderColor: 0xFF404040 focusedBorderColor: 0xFF49DEFF \ No newline at end of file diff --git a/modules/ensemble/integration_test/local/themedApp_test.dart b/modules/ensemble/integration_test/local/themedApp_test.dart index dbd9afa87..7b75b9708 100644 --- a/modules/ensemble/integration_test/local/themedApp_test.dart +++ b/modules/ensemble/integration_test/local/themedApp_test.dart @@ -27,10 +27,10 @@ void main() { expect( (originalText.decoration?.border as UnderlineInputBorder) .borderRadius, - BorderRadius.circular(5)); + BorderRadius.circular(5.5)); expect(originalText.decoration?.border?.borderSide.color, const Color(0xFF404040)); - expect(originalText.decoration?.border?.borderSide.width, 1.0); + expect(originalText.decoration?.border?.borderSide.width, 1.5); expect(originalText.decoration?.focusedBorder?.borderSide.color, const Color(0xFF49DEFF)); @@ -48,8 +48,8 @@ void main() { expect( (updatedStyleText.decoration?.border as UnderlineInputBorder) .borderRadius, - BorderRadius.circular(10)); - expect(updatedStyleText.decoration?.border?.borderSide.width, 2.0); + BorderRadius.circular(10.5)); + expect(updatedStyleText.decoration?.border?.borderSide.width, 2.5); expect(updatedStyleText.decoration?.border?.borderSide.color, const Color(0xFFCFA17B)); expect(updatedStyleText.decoration?.focusedBorder?.borderSide.color, @@ -63,8 +63,8 @@ void main() { expect(boxStyleText.decoration?.border is OutlineInputBorder, true); expect( (boxStyleText.decoration?.border as OutlineInputBorder).borderRadius, - BorderRadius.circular(99)); - expect(boxStyleText.decoration?.border?.borderSide.width, 1.0); + BorderRadius.circular(99.5)); + expect(boxStyleText.decoration?.border?.borderSide.width, 0.5); }); }); } diff --git a/modules/ensemble/lib/action/action_invokable.dart b/modules/ensemble/lib/action/action_invokable.dart index 5ca026e9c..742715efa 100644 --- a/modules/ensemble/lib/action/action_invokable.dart +++ b/modules/ensemble/lib/action/action_invokable.dart @@ -44,6 +44,7 @@ abstract class ActionInvokable with Invokable { ActionType.showDialog, ActionType.dismissDialog, ActionType.closeAllDialogs, + ActionType.dismissTooltip, ActionType.executeActionGroup, ActionType.takeScreenshot, ActionType.saveFile, diff --git a/modules/ensemble/lib/action/tooltip_actions.dart b/modules/ensemble/lib/action/tooltip_actions.dart new file mode 100644 index 000000000..0010c0e6d --- /dev/null +++ b/modules/ensemble/lib/action/tooltip_actions.dart @@ -0,0 +1,25 @@ +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/util/ensemble_utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; + +/// Ensemble action that closes the active TV tooltip. +/// +/// Mirrors `dismissDialog` / `dismissBottomSheet`: a tooltip is not a route, so +/// dismissal is delegated to the shared TV tooltip registry through +/// [EnsembleUtils.dismissTooltip]. Focus is restored to the tooltip's anchor +/// when it closes. +class DismissTooltipAction extends EnsembleAction { + /// Creates a [DismissTooltipAction] action. + DismissTooltipAction({super.initiator}); + + /// Creates a [DismissTooltipAction] from a YAML or map action payload. + factory DismissTooltipAction.from({Invokable? initiator, Map? payload}) => + DismissTooltipAction(initiator: initiator); + + /// Runs this action and closes the active tooltip. + @override + Future execute(BuildContext context, ScopeManager scopeManager) => + EnsembleUtils.dismissTooltip(context); +} diff --git a/modules/ensemble/lib/controller/controller_mixins.dart b/modules/ensemble/lib/controller/controller_mixins.dart index 7a34fdc6b..4971d039a 100644 --- a/modules/ensemble/lib/controller/controller_mixins.dart +++ b/modules/ensemble/lib/controller/controller_mixins.dart @@ -16,7 +16,7 @@ mixin HasPassThrough on EnsembleWidgetController { mixin HasBorderController on EnsembleWidgetController { LinearGradient? borderGradient; Color? borderColor; - int? borderWidth; + double? borderWidth; EBorderRadius? borderRadius; BorderStyleComposite? borderStyle; @@ -24,7 +24,7 @@ mixin HasBorderController on EnsembleWidgetController { 'borderGradient': (value) => borderGradient = Utils.getBackgroundGradient(value), 'borderColor': (value) => borderColor = Utils.getColor(value), - 'borderWidth': (value) => borderWidth = Utils.optionalInt(value), + 'borderWidth': (value) => borderWidth = Utils.optionalDouble(value), 'borderRadius': (value) => borderRadius = Utils.getBorderRadius(value), 'borderStyle': (value) => borderStyle = BorderStyleComposite.from(this, value), diff --git a/modules/ensemble/lib/ensemble_app.dart b/modules/ensemble/lib/ensemble_app.dart index 5dafb3336..6fa592515 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'; @@ -111,6 +112,7 @@ class EnsembleApp extends StatefulWidget { this.onAppLoad, this.forcedLocale, this.child, + this.tvFocusProvider, GlobalKey? navigatorKey, ScrollController? screenScroller, }) { @@ -142,6 +144,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(); } @@ -450,6 +458,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; } @@ -534,6 +555,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/action.dart b/modules/ensemble/lib/framework/action.dart index 71e9482dd..ff4094c77 100644 --- a/modules/ensemble/lib/framework/action.dart +++ b/modules/ensemble/lib/framework/action.dart @@ -27,6 +27,7 @@ import 'package:ensemble/action/notification_actions.dart'; import 'package:ensemble/action/saveFile/save_file.dart'; import 'package:ensemble/action/connectivity_action.dart'; import 'package:ensemble/action/phone_contact_action.dart'; +import 'package:ensemble/action/tooltip_actions.dart'; import 'package:ensemble/action/secure_storage.dart'; import 'package:ensemble/action/sign_in_out_action.dart'; import 'package:ensemble/action/sign_in_with_verification_code_actions.dart'; @@ -1007,6 +1008,7 @@ enum ActionType { dismissBottomModal, showDialog, dismissDialog, + dismissTooltip, @Deprecated("use dismissDialog") closeAllDialogs, startTimer, @@ -1178,6 +1180,8 @@ abstract class EnsembleAction { return ShowDialogAction.from(initiator: initiator, payload: payload); } else if (actionType == ActionType.dismissDialog) { return DismissDialogAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.dismissTooltip) { + return DismissTooltipAction.from(initiator: initiator, payload: payload); } else if (actionType == ActionType.closeAllDialogs) { return CloseAllDialogsAction(); } else if (actionType == ActionType.startTimer) { diff --git a/modules/ensemble/lib/framework/device.dart b/modules/ensemble/lib/framework/device.dart index 785bef6c6..a8154f46d 100644 --- a/modules/ensemble/lib/framework/device.dart +++ b/modules/ensemble/lib/framework/device.dart @@ -57,6 +57,9 @@ class Device "macOsInfo": () => DeviceMacOsInfo(), "windowsInfo": () => DeviceWindowsInfo(), + // TV detection + "isTV": () => isTV, + // @deprecated. backward compatibility DevicePlatform.web.name: () => DeviceWebInfo() }; @@ -70,6 +73,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), @@ -87,6 +91,14 @@ class Device void openAppSettings([String? target]) { AppSettingAction.openAppSettings(target ?? 'settings'); } + + /// Test-only override of [DeviceInfoCapability.isTV] so widget tests can + /// exercise TV-only rendering without real Android device info. + /// Pass null to clear the override and fall back to platform detection. + @visibleForTesting + static void debugOverrideIsTV(bool? value) { + DeviceInfoCapability._isTVOverride = value; + } } mixin MediaQueryCapability { @@ -125,8 +137,49 @@ mixin DeviceInfoCapability { static MacOsDeviceInfo? macOsInfo; static WindowsDeviceInfo? windowsInfo; + // Android TV detection cache + static bool? _isTV; + + /// Test-only override consulted before device detection, so a test build can + /// pretend to run on a TV without populating [androidInfo]. Declared here (not + /// in [Device]) because [isTV] reads it. See [Device.debugOverrideIsTV]. + static bool? _isTVOverride; + DevicePlatform? get platform => _platform; + /// Returns true if the device is an Android TV + /// Checks for TV-specific system features + bool get isTV { + if (_isTVOverride != null) return _isTVOverride!; + if (_isTV != null) return _isTV!; + + // Web is never a TV, and it's known synchronously — safe to cache. + if (kIsWeb) { + _isTV = false; + return false; + } + + // Only Android devices can be TVs. androidInfo is populated + // asynchronously by initDeviceInfo(), and is only ever set on Android, so + // androidInfo == null means either a non-Android device or Android info + // that hasn't loaded yet. In both cases return false WITHOUT caching: + // caching here would permanently disable TV mode for the whole session if + // any widget reads isTV before initDeviceInfo() completes. Non-Android + // devices simply keep returning false cheaply; Android re-evaluates once + // androidInfo is available. + if (androidInfo == null) { + return false; + } + + // Check for TV system features (result is now final — cache it). + 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/ensemble_widget.dart b/modules/ensemble/lib/framework/ensemble_widget.dart index 9c6b5df1d..9a567b4db 100644 --- a/modules/ensemble/lib/framework/ensemble_widget.dart +++ b/modules/ensemble/lib/framework/ensemble_widget.dart @@ -22,6 +22,25 @@ abstract class EnsembleWidget } abstract class EnsembleWidgetState extends State { + ScopeManager? _registeredScope; + EnsembleController? _registeredController; + + void _syncBindingOwner(ScopeManager? scope) { + final controller = widget.controller; + if (identical(_registeredScope, scope) && + identical(_registeredController, controller)) { + return; + } + + PageBindingManager.releaseBindingOwner( + _registeredScope, _registeredController); + _registeredScope = scope; + _registeredController = controller; + if (scope != null) { + PageBindingManager.retainBindingOwner(scope, controller); + } + } + void _update() { setState(() {}); } @@ -36,12 +55,22 @@ abstract class EnsembleWidgetState extends State { void didUpdateWidget(covariant W oldWidget) { super.didUpdateWidget(oldWidget); oldWidget.controller.removeListener(_update); + if (!identical(oldWidget.controller, widget.controller)) { + // Release the old controller immediately. The next build will register + // the replacement controller in the same scope. + PageBindingManager.releaseBindingOwner( + _registeredScope, _registeredController); + _registeredController = null; + } widget.controller.addListener(_update); } @override void dispose() { widget.controller.removeListener(_update); + PageBindingManager.releaseBindingOwner( + _registeredScope, _registeredController, + preserveRegistration: true); super.dispose(); } @@ -49,6 +78,11 @@ abstract class EnsembleWidgetState extends State { @override Widget build(BuildContext context) { + // Cache the scope while the element is active; it can't be looked up in + // dispose(). Keep binding ownership scoped to this page and controller. + final scope = DataScopeWidget.getScope(context); + _syncBindingOwner(scope); + scope?.restoreBindingListeners(widget.controller, ownerScope: scope); if (widget.controller is EnsembleWidgetController) { EnsembleWidgetController widgetController = widget.controller as EnsembleWidgetController; diff --git a/modules/ensemble/lib/framework/model.dart b/modules/ensemble/lib/framework/model.dart index b4e4d6075..042cfd2d8 100644 --- a/modules/ensemble/lib/framework/model.dart +++ b/modules/ensemble/lib/framework/model.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/util/utils.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; /// misc models @@ -19,14 +20,20 @@ class BackgroundImage { BoxFit? fit, Alignment? alignment, dynamic fallback, + dynamic loadingWidget, + BaseCacheManager? cacheManager, }) : _fit = fit ?? BoxFit.cover, _alignment = alignment ?? Alignment.center, - _fallback = fallback; + _fallback = fallback, + _loadingWidget = loadingWidget, + _cacheManager = cacheManager; final String _source; final BoxFit _fit; final Alignment _alignment; final dynamic _fallback; + final dynamic _loadingWidget; + final BaseCacheManager? _cacheManager; DecorationImage getImageAsDecorated() { ImageProvider imageProvider; @@ -52,6 +59,9 @@ class BackgroundImage { final Widget? fallbackWidget = _fallback != null ? scopeManager?.buildWidgetFromDefinition(_fallback) : null; + final Widget? loadingWidget = _loadingWidget != null + ? scopeManager?.buildWidgetFromDefinition(_loadingWidget) + : null; if (Utils.isUrl(_source)) { String assetName = Utils.getAssetName(_source); @@ -68,6 +78,8 @@ class BackgroundImage { imageUrl: _source, fit: _fit, alignment: _alignment, + cacheManager: _cacheManager, + placeholder: loadingWidget != null ? (_, __) => loadingWidget : null, errorWidget: fallbackWidget != null ? (_, __, ___) => fallbackWidget : null, ); @@ -85,37 +97,37 @@ class BackgroundImage { class EBorderRadius { EBorderRadius._( - int _topLeft, int _topRight, int _bottomRight, int _bottomLeft) + double _topLeft, double _topRight, double _bottomRight, double _bottomLeft) : topLeft = - _topLeft == 0 ? Radius.zero : Radius.circular(_topLeft.toDouble()), + _topLeft == 0 ? Radius.zero : Radius.circular(_topLeft), topRight = _topRight == 0 ? Radius.zero - : Radius.circular(_topRight.toDouble()), + : Radius.circular(_topRight), bottomRight = _bottomRight == 0 ? Radius.zero - : Radius.circular(_bottomRight.toDouble()), + : Radius.circular(_bottomRight), bottomLeft = _bottomLeft == 0 ? Radius.zero - : Radius.circular(_bottomLeft.toDouble()); + : Radius.circular(_bottomLeft); Radius topLeft, topRight, bottomRight, bottomLeft; - factory EBorderRadius.all(int val) { + factory EBorderRadius.all(double val) { return EBorderRadius._(val, val, val, val); } // first value: top-left & bottom-right // second value: top-right & bottom-left - factory EBorderRadius.two(int first, int second) { + factory EBorderRadius.two(double first, double second) { return EBorderRadius._(first, second, first, second); } // first value: top-left // second value: top-right & bottom-left // third value: bottom-right - factory EBorderRadius.three(int first, int second, int third) { + factory EBorderRadius.three(double first, double second, double third) { return EBorderRadius._(first, second, third, second); } factory EBorderRadius.only( - int topLeft, int topRight, int bottomRight, int bottomLeft) { + double topLeft, double topRight, double bottomRight, double bottomLeft) { return EBorderRadius._(topLeft, topRight, bottomRight, bottomLeft); } diff --git a/modules/ensemble/lib/framework/route_observer.dart b/modules/ensemble/lib/framework/route_observer.dart index 0f93f0b79..99de3de79 100644 --- a/modules/ensemble/lib/framework/route_observer.dart +++ b/modules/ensemble/lib/framework/route_observer.dart @@ -1,6 +1,7 @@ import 'package:ensemble/ensemble.dart'; import 'package:ensemble/framework/screen_tracker.dart'; import 'package:ensemble/navigation/ensemble_navigation_manager.dart'; +import 'package:ensemble/framework/tv/tv_focus_widget.dart'; import 'package:flutter/cupertino.dart'; // handle the listeners to listen for Route changes @@ -14,6 +15,9 @@ mixin EnsembleRouteObserver on WithEnsemble { // Screen tracking observer late final ScreenTrackingNavigatorObserver _screenTrackingObserver; + // Frees TVFocusWidget's per-route row-position memory on route exit + late final TVFocusRouteObserver _tvFocusRouteObserver; + final List routeObservers = []; initializeRouteObservers() { @@ -29,6 +33,9 @@ mixin EnsembleRouteObserver on WithEnsemble { // Register last so tracking observes the same Navigator event before the // manager publishes its canonical history snapshot. routeObservers.add(EnsembleNavigationManager.instance.observer); + + _tvFocusRouteObserver = TVFocusRouteObserver(); + routeObservers.add(_tvFocusRouteObserver); } // return the current top-most Route for our App diff --git a/modules/ensemble/lib/framework/scope.dart b/modules/ensemble/lib/framework/scope.dart index ad0161f57..88ae3f591 100644 --- a/modules/ensemble/lib/framework/scope.dart +++ b/modules/ensemble/lib/framework/scope.dart @@ -67,13 +67,39 @@ class ScopeManager extends IsScopeManager with ViewBuilder, PageBindingManager { /// call when the screen is being disposed /// TODO: consolidate listeners, location, eventBus, ... void dispose() { + if (_parent != null) { + // Child scopes share the page event bus and PageData timers/location + // resources, but own their binding registrations. Remove those without + // tearing down the shared page resources. + PageBindingManager.clearBindingOwnersForScope(this); + return; + } + // NOTE: `isDisposed` means "this page's root scope was disposed". Child // scopes share the page's event bus/timers, so disposing one must not mark - // the page disposed. (The shared-resource teardown split itself is handled - // by the binding-owner work, not here.) - if (_parent == null) { - pageData._disposed = true; + // the page disposed; the early return above covers that case. + pageData._disposed = true; + + PageBindingManager.clearBindingOwners(pageData); + + // Cancel and release all page-owned bindings before destroying the event + // bus. Child scopes share these subscriptions through PageData. + for (final subscriptions in listenerMap.values) { + for (final subscription in subscriptions.values) { + subscription.cancel(); + } + } + for (final value in dataContext.contextMap.values) { + if (value is Invokable) { + PageBindingManager.clearBindingRegistrations(value); + } + } + for (final destination in listenerMap.keys) { + PageBindingManager.clearBindingRegistrations(destination); } + listenerMap.clear(); + openedDialogs.clear(); + // clear out all event listeners eventBus.destroy(); @@ -81,13 +107,16 @@ class ScopeManager extends IsScopeManager with ViewBuilder, PageBindingManager { pageData._timerMap.forEach((_, timer) { timer.cancel(); }); + pageData._timerMap.clear(); // cancel all standalone timers for (var timer in pageData._timers) { timer.cancel(); } + pageData._timers.clear(); // cancel the screen's location listener pageData.locationListener?.cancel(); + pageData.locationListener = null; SocketService().dispose(); } @@ -542,32 +571,180 @@ mixin ViewBuilder on IsScopeManager { /// managing binding at the Page level. /// It does this by tapping into the page-level's PageData mixin PageBindingManager on IsScopeManager { + static final Expando> _bindingRegistrations = + Expando>('Ensemble binding registrations'); + static final Map>> + _bindingOwnerCounts = {}; + + static void retainBindingOwner( + ScopeManager ownerScope, Invokable destination) { + final destinations = + _bindingOwnerCounts.putIfAbsent(ownerScope.pageData, () => {}); + final scopes = destinations.putIfAbsent(destination, () => {}); + scopes[ownerScope] = (scopes[ownerScope] ?? 0) + 1; + } + + @visibleForTesting + static int debugBindingOwnerCount(PageData pageData) { + final destinations = _bindingOwnerCounts[pageData]; + if (destinations == null) return 0; + return destinations.values.fold( + 0, (count, scopes) => count + scopes.values.fold(0, (a, b) => a + b)); + } + + static void clearBindingOwners(PageData pageData) { + final destinations = _bindingOwnerCounts.remove(pageData); + if (destinations == null) return; + for (final destination in destinations.keys) { + clearBindingRegistrations(destination); + } + } + + static void clearBindingOwnersForScope(ScopeManager ownerScope) { + final destinations = _bindingOwnerCounts[ownerScope.pageData]; + if (destinations == null) return; + for (final destination in destinations.keys.toList()) { + ownerScope.removeBindingListenersForScope(destination, ownerScope); + final scopes = destinations[destination]; + scopes?.remove(ownerScope); + if (scopes != null && scopes.isEmpty) { + destinations.remove(destination); + } + } + if (destinations.isEmpty) { + _bindingOwnerCounts.remove(ownerScope.pageData); + } + } + + static void releaseBindingOwner( + ScopeManager? ownerScope, Invokable? destination, + {bool preserveRegistration = false}) { + if (ownerScope == null || destination == null) return; + final destinations = _bindingOwnerCounts[ownerScope.pageData]; + final scopes = destinations?[destination]; + final count = scopes?[ownerScope]; + if (count == null) return; + + if (count > 1) { + scopes![ownerScope] = count - 1; + return; + } + + scopes!.remove(ownerScope); + ownerScope.removeBindingListenersForScope(destination, ownerScope, + preserveRegistration: preserveRegistration); + if (scopes.isEmpty) { + destinations!.remove(destination); + } + if (destinations!.isEmpty) { + _bindingOwnerCounts.remove(ownerScope.pageData); + } + } + + static void clearBindingRegistrations(Invokable destinationWidget) { + _bindingRegistrations[destinationWidget]?.clear(); + _bindingRegistrations[destinationWidget] = null; + } + /// Evaluate a binding expression and listen for changes. /// Calling this multiple times is safe as we remove the matching listeners before adding. /// Upon changes, execute setProperty() on the destination's Invokable /// The expression can be a mix of variable and text e.g Hello $(first) $(last) void registerBindingListener(ScopeManager scopeManager, BindingDestination bindingDestination, DataExpression dataExpression) { - // we re-evaluate the entire raw binding upon any changes to any variables - for (var expression in dataExpression.expressions) { - listen(scopeManager, expression, destination: bindingDestination, - onDataChange: (ModelChangeEvent event) { - DataContext dataContext = scopeManager.dataContext; - /* - if (dataContext.getContextById(event.modelId) is InvokablePrimitive) { - updateInvokablePrimitive( - dataContext, - event.modelId, - dataContext.getContextById(bindingDestination.setterProperty), - event.payload); + final ownerScope = bindingDestination.widget is CustomWidgetController + ? (bindingDestination.widget as CustomWidgetController).scopeManager + : scopeManager; + final existingRegistrations = + _bindingRegistrations[bindingDestination.widget]; + if (existingRegistrations != null) { + existingRegistrations + .removeWhere((registration) => registration.scopeManager == null); + for (final oldRegistration + in List<_BindingRegistration>.of(existingRegistrations)) { + final oldOwnerScope = oldRegistration.ownerScope; + if (oldRegistration.setterProperty == + bindingDestination.setterProperty && + oldRegistration.scopeManager != null && + oldOwnerScope != null && + !identical(oldOwnerScope, ownerScope)) { + removeBindingListenersForScope( + bindingDestination.widget, oldOwnerScope); } - */ - // payload only have changes to a variable, but we have to evaluate the entire expression - // e.g Hello $(firstName.value) $(lastName.value) - dynamic updatedValue = dataContext.eval(dataExpression.rawExpression); - InvokableController.setProperty(bindingDestination.widget, - bindingDestination.setterProperty, updatedValue); - }); + } + } + final registrations = _bindingRegistrations[bindingDestination.widget] ??= + <_BindingRegistration>[]; + final existingIndex = registrations.indexWhere((registration) => + identical(registration.ownerScope, ownerScope) && + registration.setterProperty == bindingDestination.setterProperty); + final registration = _BindingRegistration(scopeManager, ownerScope, + bindingDestination.setterProperty, dataExpression); + if (existingIndex == -1) { + registrations.add(registration); + } else { + _removeRegistrationSubscriptions( + bindingDestination, registrations[existingIndex]); + registrations[existingIndex] = registration; + } + + // We re-evaluate the entire raw binding upon any changes to any variables. + for (final expression in dataExpression.expressions) { + listen(scopeManager, expression, + destination: bindingDestination, + onDataChange: (_) => _updateBindingValue( + scopeManager, bindingDestination, dataExpression)); + } + } + + void _updateBindingValue(ScopeManager scopeManager, + BindingDestination destination, DataExpression expression) { + final updatedValue = + scopeManager.dataContext.eval(expression.rawExpression); + InvokableController.setProperty( + destination.widget, destination.setterProperty, updatedValue); + } + + /// Restore binding subscriptions for a destination whose widget state was + /// disposed and later remounted from a cached widget instance. + void restoreBindingListeners(Invokable destinationWidget, + {ScopeManager? ownerScope}) { + final registrations = _bindingRegistrations[destinationWidget]; + if (registrations == null) return; + for (final registration in List<_BindingRegistration>.of(registrations)) { + if (ownerScope != null && + !identical(registration.ownerScope, ownerScope)) { + continue; + } + final scopeManager = registration.scopeManager; + if (scopeManager == null) { + registrations.remove(registration); + continue; + } + final destination = + BindingDestination(destinationWidget, registration.setterProperty); + for (final expression in registration.expression.expressions) { + var sources = BindingSource.getBindingSources( + expression, scopeManager.dataContext); + if (sources.isEmpty) { + final fallback = + BindingSource.from(expression, scopeManager.dataContext); + if (fallback != null) sources = [fallback]; + } + for (final source in sources) { + final hash = getHash( + destinationSetter: destination.setterProperty, + source: source, + scopeManager: _bindingHashScope(source, scopeManager)); + if (listenerMap[destinationWidget]?.containsKey(hash) ?? false) { + continue; + } + _listenToBindingSource(scopeManager, source, + destination: destination, + onDataChange: (_) => _updateBindingValue( + scopeManager, destination, registration.expression)); + } + } } } @@ -592,7 +769,74 @@ mixin PageBindingManager on IsScopeManager { // if (count != null) { // log("Removing ${count} binding listeners for (${destinationWidget.runtimeType} - ${destinationWidget.hashCode})"); // } - listenerMap[destinationWidget]?.values.forEach((e) => e.cancel()); + // Remove the entry as well, otherwise the page-scoped listenerMap keeps + // growing with keys for disposed widgets (and the cancelled subscriptions + // they hold) every time a dialog/overlay is opened and closed. + listenerMap.remove(destinationWidget)?.values.forEach((e) => e.cancel()); + } + + /// Remove only registrations created from [bindingScope], leaving other + /// nested scopes that share this page-level listener map intact. + void removeBindingListenersForScope( + Invokable destinationWidget, ScopeManager bindingScope, + {bool preserveRegistration = false}) { + final registrations = _bindingRegistrations[destinationWidget]; + final subscriptions = listenerMap[destinationWidget]; + if (registrations == null) { + // No managed registrations means this destination subscribed through a + // direct listen() call (e.g. Conditional/TabBar). Cancel its + // subscriptions so disposal does not leak them. + listenerMap.remove(destinationWidget)?.values.forEach((e) => e.cancel()); + return; + } + if (subscriptions == null) { + if (!preserveRegistration) { + registrations.removeWhere( + (registration) => identical(registration.ownerScope, bindingScope)); + } + if (registrations.isEmpty) clearBindingRegistrations(destinationWidget); + return; + } + + for (final registration in List<_BindingRegistration>.of(registrations)) { + final registeredScope = registration.ownerScope; + if (!identical(registeredScope, bindingScope)) continue; + _removeRegistrationSubscriptions( + BindingDestination(destinationWidget, registration.setterProperty), + registration, + subscriptions: subscriptions); + if (!preserveRegistration) registrations.remove(registration); + } + + if (subscriptions.isEmpty) listenerMap.remove(destinationWidget); + if (registrations.isEmpty) clearBindingRegistrations(destinationWidget); + } + + void _removeRegistrationSubscriptions( + BindingDestination destination, _BindingRegistration registration, + {Map? subscriptions}) { + final bindingScope = registration.scopeManager; + if (bindingScope == null) return; + final destinationSubscriptions = + subscriptions ?? listenerMap[destination.widget]; + if (destinationSubscriptions == null) return; + + for (final expression in registration.expression.expressions) { + var bindingSources = + BindingSource.getBindingSources(expression, bindingScope.dataContext); + if (bindingSources.isEmpty) { + final fallback = + BindingSource.from(expression, bindingScope.dataContext); + if (fallback != null) bindingSources = [fallback]; + } + for (final source in bindingSources) { + final hash = getHash( + destinationSetter: registration.setterProperty, + source: source, + scopeManager: _bindingHashScope(source, bindingScope)); + destinationSubscriptions.remove(hash)?.cancel(); + } + } } /// listen for changes on the bindingExpression and invoke onDataChange() callback. @@ -682,6 +926,14 @@ mixin PageBindingManager on IsScopeManager { source.runtimeType, scopeManager); } + /// The scope that participates in the listener hash for [source]. Simple and + /// deferred sources need the scope to be uniquely identified (e.g. custom + /// widget input variables); other sources are page-wide. + ScopeManager? _bindingHashScope(BindingSource source, ScopeManager scope) => + (source is SimpleBindingSource || source is DeferredBindingSource) + ? scope + : null; + /// print a map of the current listeners on this scope void debugListenerMap() { listenerMap.forEach((widget, map) { @@ -692,6 +944,20 @@ mixin PageBindingManager on IsScopeManager { } } +class _BindingRegistration { + _BindingRegistration(ScopeManager scopeManager, ScopeManager ownerScope, + this.setterProperty, this.expression) + : _scopeManager = WeakReference(scopeManager), + _ownerScope = WeakReference(ownerScope); + + final WeakReference _scopeManager; + ScopeManager? get scopeManager => _scopeManager.target; + final WeakReference _ownerScope; + ScopeManager? get ownerScope => _ownerScope.target; + final String setterProperty; + final DataExpression expression; +} + /// data for the current Page. class PageData { PageData( diff --git a/modules/ensemble/lib/framework/theme/theme_loader.dart b/modules/ensemble/lib/framework/theme/theme_loader.dart index a22d94b14..03ce37044 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'; @@ -10,7 +11,7 @@ import 'package:yaml/yaml.dart'; mixin ThemeLoader { final EdgeInsets _buttonPadding = const EdgeInsets.only(left: 15, top: 5, right: 15, bottom: 5); - final int _buttonBorderRadius = 3; + final double _buttonBorderRadius = 3; final Color _buttonBorderOutlineColor = Colors.black12; bool hasLegacyCustomAppTheme(YamlMap? overrides) { return overrides?['App'] != null @@ -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), ) ]); } @@ -280,7 +286,7 @@ mixin ThemeLoader { BorderRadius borderRadius = Utils.getBorderRadius(input['borderRadius'])?.getValue() ?? getInputDefaultBorderRadius(variant); - int borderWidth = Utils.optionalInt(input['borderWidth']) ?? 1; + double borderWidth = Utils.optionalDouble(input['borderWidth']) ?? 1; Color? borderColor = Utils.getColor(input['borderColor']); Color? disabledBorderColor = Utils.getColor(input['disabledBorderColor']); @@ -299,7 +305,7 @@ mixin ThemeLoader { (colorScheme.brightness == Brightness.light ? Colors.black54 : Colors.white70), - width: borderWidth.toDouble())); + width: borderWidth)); return baseInputDecoration.copyWith( contentPadding: contentPadding ?? @@ -335,7 +341,7 @@ mixin ThemeLoader { (colorScheme.brightness == Brightness.light ? Colors.black87 : Colors.white70), - width: borderWidth.toDouble())); + width: borderWidth)); return baseInputDecoration.copyWith( contentPadding: contentPadding ?? const EdgeInsets.symmetric(vertical: 15, horizontal: 3), @@ -380,15 +386,15 @@ mixin ThemeLoader { isOutline ? null : Utils.getColor(input['backgroundColor']); RoundedRectangleBorder border = RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - Utils.getInt(input['borderRadius'], fallback: _buttonBorderRadius) - .toDouble()), + borderRadius: input['borderRadius'] == 0 + ? BorderRadius.zero + : Utils.getBorderRadius(input['borderRadius'])?.getValue() ?? + BorderRadius.circular(_buttonBorderRadius), side: borderColor == null ? BorderSide.none : BorderSide( color: borderColor, - width: Utils.getInt(input['borderWidth'], fallback: 1) - .toDouble())); + width: Utils.getDouble(input['borderWidth'], fallback: 1))); return getButtonStyle( isOutline: isOutline, @@ -401,20 +407,20 @@ mixin ThemeLoader { InputBorder? getInputBorder( {InputVariant? variant, Color? borderColor, - required int borderWidth, + required double borderWidth, required BorderRadius borderRadius}) { if (borderColor != null) { if (variant == InputVariant.box) { return OutlineInputBorder( borderRadius: borderRadius, borderSide: - BorderSide(color: borderColor, width: borderWidth.toDouble())); + BorderSide(color: borderColor, width: borderWidth)); } // default is underline return UnderlineInputBorder( borderRadius: borderRadius, borderSide: - BorderSide(color: borderColor, width: borderWidth.toDouble())); + BorderSide(color: borderColor, width: borderWidth)); } return null; } @@ -457,21 +463,21 @@ mixin ThemeLoader { Color? fillColor = Utils.getColor(input?["fillColor"]); Color? activeColor = Utils.getColor(input?["activeColor"]); Color? checkColor = Utils.getColor(input?["checkColor"]); - int borderWidth = Utils.optionalInt(input?['borderWidth'], min: 0) ?? 2; + double borderWidth = Utils.optionalDouble(input?['borderWidth'], min: 0) ?? 2; var checkboxTheme = CheckboxThemeData( side: WidgetStateBorderSide.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return BorderSide( - width: borderWidth.toDouble(), color: DesignSystem.disableColor); + width: borderWidth, color: DesignSystem.disableColor); } if (states.contains(WidgetState.error)) { return BorderSide( - width: borderWidth.toDouble(), + width: borderWidth, color: DesignSystem.inputErrorColor); } if (!states.contains(WidgetState.selected)) { - return BorderSide(width: borderWidth.toDouble(), color: borderColor); + return BorderSide(width: borderWidth, color: borderColor); } // use default return null; @@ -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..e6f3e15cb --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_context.dart @@ -0,0 +1,43 @@ +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.rememberRowPosition, + this.rightEdge, + this.leftEdge, + this.topEdge, + this.bottomEdge, + }); + + final String? focusGroup; + + /// Inherited row-position-memory opt-in. Null = not provided at this level. + final bool? rememberRowPosition; + 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 || + rememberRowPosition != oldWidget.rememberRowPosition || + 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..85ed4799b --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_navigation.dart @@ -0,0 +1,111 @@ +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; +} + +/// Resolves the effective `rememberRowPosition` for a widget: its own tvOptions +/// value if set, else the nearest inherited [TVFocusContext] value, else false. +bool resolveTVRememberRowPosition( + BuildContext context, + TVOptionsComposite tvOptions, +) { + return tvOptions.rememberRowPosition ?? + TVFocusContext.maybeOf(context)?.rememberRowPosition ?? + false; +} + +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 && + tvOptions?.rememberRowPosition == null) { + return child; + } + + final inheritedContext = TVFocusContext.maybeOf(context); + return TVFocusContext( + focusGroup: tvOptions?.focusGroup ?? inheritedContext?.focusGroup, + rememberRowPosition: + tvOptions?.rememberRowPosition ?? inheritedContext?.rememberRowPosition, + 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..17cff177f --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_order.dart @@ -0,0 +1,635 @@ +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. +/// +/// ## 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, + rememberRowPosition = false; + + /// 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, + this.rememberRowPosition = false, + }); + + 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; + + /// When true, vertical navigation remembers the last focused column per row + /// (scoped per screen + [focusGroup]) and returns to it. Default false = + /// grid-style column preservation. Resolved from tvOptions (own ?? inherited). + final bool rememberRowPosition; + + /// 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, + rememberRowPosition: rememberRowPosition, + ); + } + + /// 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); + // PHASE 1: unified collection (was registry + descendants scan inlined here). + final candidatesByOrder = TVFocusOrderNode.collectInScope( + route: route, + ); + + 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); + // PHASE 1: unified collection (was registry + descendants scan inlined here). + final candidatesByOrder = TVFocusOrderNode.collectInScope( + route: route, + focusGroup: focusGroup, + ); + final rowNodesByOrder = {}; + for (final node in candidatesByOrder.values) { + if (node.order.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); + // PHASE 1: unified collection (was registry + descendants scan inlined here). + final candidatesByOrder = TVFocusOrderNode.collectInScope( + route: route, + focusGroup: targetFocusGroup, + ); + + 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, + }) { + // Finds the row in the already-built [grid] whose row index is closest to + // [target]. Scans `grid` directly — no need to flatten + rebuild via + // _findNearestRow. + List? nearestRowTo(double target) { + List? nearest; + var nearestDiff = double.infinity; + for (final rowNodes in grid) { + final diff = (rowNodes.first.order.row - target).abs(); + if (diff < nearestDiff) { + nearest = rowNodes; + nearestDiff = diff; + } + } + return nearest; + } + + if (targetRow != null) { + return nearestRowTo(targetRow); + } + + switch (direction) { + case TVFocusDirection.right: + case TVFocusDirection.left: + if (currentRow == null) { + return grid.first; + } + return nearestRowTo(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(); + } + + @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 int _focusNodeDepth(FocusNode focusNode) { + var depth = 0; + focusNode.context?.visitAncestorElements((_) { + depth++; + return true; + }); + return depth; + } + + // ───────────────────────────────────────────────────────────────────────── + // Shared candidate collection — single source of truth for "all in-scope TV + // focus candidates", used by _moveFocus / requestFocus / requestFocusAt / + // requestFocusByEdge. + // + // Two complementary passes: + // PASS 1 (registry, O(r)): Case-1 widgets that expose a real requestable + // FocusNode via primaryFocusNode (e.g. box_wrapper, tabs) register it + // directly. Fast and always accurate for those. + // PASS 2 (live focus-tree scan): Case-2 widgets wrap passive content in an + // anonymous FocusScope and do NOT register (e.g. bracket tiles/tabs); their + // real focusable leaf is only reachable by walking the mounted focus tree. + // Without this pass, navigation into those widgets has no candidate to + // land on and focus is lost. The scan finds the actual leaf (not an empty + // scope), so a visible ring is produced. + // Registered leaves take precedence on ties (see isBetterCandidateNode), so + // Case-1 widgets found by both passes resolve to the registered node. + // + // Arguments: + // - [traversalGroup] null => no FocusTraversalGroup filtering (matches the + // tv_focus_order call sites). Non-null => restrict to that group (matches + // _moveFocus). + // - [focusGroup] null => all focus groups. + // - [includeDescendantScan] when false, skips PASS 2 (the app-wide live + // focus-tree walk). Callers that know every focusable in scope registers a + // [TVFocusTarget] (e.g. the host's PageFocusWidget) can set this to false to + // avoid an O(all focus nodes) scan on every D-pad press. Defaults to true so + // existing behavior (Case-2 bracket/tab leaves) is unchanged. + // ───────────────────────────────────────────────────────────────────────── + static Map collectInScope({ + required ModalRoute? route, + FocusTraversalGroup? traversalGroup, + String? focusGroup, + bool includeDescendantScan = true, + }) { + final result = {}; + + // PASS 1 — registered Case-1 targets (O(r)). + for (final target in TVFocusRegistry.targets( + route: route, + traversalGroup: traversalGroup, + focusGroup: focusGroup, + )) { + final order = target.focusOrder as TVFocusOrder; + addPreferredCandidate( + result, + TVFocusOrderNode(target.focusNode, order, isRegisteredTarget: true), + ); + } + + if (!includeDescendantScan) { + return result; + } + + // PASS 2 — live focus-tree scan for unregistered Case-2 leaves. + for (final focusNode in FocusManager.instance.rootScope.descendants) { + final nodeContext = focusNode.context; + if (nodeContext == null) continue; + if (!focusNode.canRequestFocus) continue; + if (route != null && ModalRoute.of(nodeContext) != route) continue; + if (traversalGroup != null && + nodeContext.findAncestorWidgetOfExactType() != + traversalGroup) { + continue; + } + final order = nodeContext + .findAncestorWidgetOfExactType() + ?.order; + if (order is! TVFocusOrder) continue; + if (focusGroup != null && order.focusGroup != focusGroup) continue; + addPreferredCandidate(result, TVFocusOrderNode(focusNode, order)); + } + + return result; + } + + /// 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 +// ============================================================================= + +/// Named [ReadingOrderTraversalPolicy] for Ensemble's TV focus groups. +/// +/// Currently adds no traversal overrides of its own — row/edge boundaries +/// (including UP at row 0) are enforced by the TVFocusWidget navigation logic, +/// not here. Kept as a named seam so TV-specific traversal can be added later +/// without touching the FocusTraversalGroup call sites. +class TVFocusOrderTraversalPolicy extends ReadingOrderTraversalPolicy { + TVFocusOrderTraversalPolicy(); +} + +/// 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, + FocusScopeNode? node, + FocusNode? parentNode, + FocusOnKeyEventCallback? onKeyEvent, + required this.lockScope, + required super.child, + super.debugLabel, + this.onRightEdge, + this.onLeftEdge, + this.onTopEdge, + this.onBottomEdge, + }) : super(node: node, parentNode: parentNode, onKeyEvent: onKeyEvent); +} 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..c46130d19 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_provider.dart @@ -0,0 +1,258 @@ +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 own focus-wrapping +/// widget 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, 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 HostFocusWidget( +/// focusOrder: HostFocusOrder( +/// 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, + bool rememberRowPosition = false, + 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 a host tab is at order 5, set orderOffset to 5 + /// so navigating UP from Ensemble naturally lands on that 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 focus-order widgets at these + /// coordinates and request focus on them. + /// + /// Default implementation uses the framework's [TVFocusOrder.requestFocusAt]. + /// Override if the host app uses a different order type. + 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, + ); + } + + /// Persist [row]'s current column so vertical nav can restore it + /// (`rememberRowPosition`). Called on focus-gain. [row]/[order] are + /// relative — a host with grid offsets must add them. Default no-op. + void saveRowPosition({ + required ModalRoute? route, + required double row, + required double order, + String? focusGroup, + }) {} + + /// 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..9d31e6ec2 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_registry.dart @@ -0,0 +1,355 @@ +import 'dart:collection'; + +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.route, + this.traversalGroup, + 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; + + /// The enclosing [ModalRoute] captured at registration time. + /// + /// PHASE 3: caching the route here makes [isInRoute] an O(1) comparison + /// instead of a `ModalRoute.of` ancestor walk on every navigation query (which + /// ran for every registered target). [ModalRoute] identity is stable across + /// rebuilds, and the registrar re-registers on dependency changes, so this + /// stays current. + final ModalRoute? route; + + /// The enclosing [FocusTraversalGroup] captured at registration time. + /// + /// The group widget instance is recreated on rebuild, so it is re-captured by + /// [TVFocusTargetRegistrar._register] on every rebuild (which is when a new + /// [TVFocusTarget] is constructed). This makes [isInTraversalGroup] an O(1) + /// identity comparison instead of a `findAncestorWidgetOfExactType` ancestor + /// walk that previously ran for every registered target on every D-pad press. + final FocusTraversalGroup? traversalGroup; + + 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; + } + return this.route == route; + } + + bool isInTraversalGroup(FocusTraversalGroup? traversalGroup) { + if (traversalGroup == null) { + return true; + } + // Fast path: the group captured at registration is the one being queried. + if (identical(this.traversalGroup, traversalGroup)) { + return true; + } + // If we captured a (different) live group at registration, the target is + // known to belong to that other group and cannot be in the queried one. + // Fall back to a live walk only when registration captured nothing (e.g. a + // target registered before the group existed in its ancestry). + if (this.traversalGroup != null) { + return false; + } + final targetContext = effectiveContext; + return targetContext + ?.findAncestorWidgetOfExactType() == + traversalGroup; + } +} + +/// Route-aware registry of explicit TV focus targets. +/// +/// Keeps two views of the same registrations: +/// - [_targets]: flat map keyed by [FocusNode] (used by the legacy +/// [targets] scan, kept for backward compatibility). +/// - [_index]: a route → row → order index built on registration so navigation +/// can resolve a neighbour with direct lookups instead of rebuilding the whole +/// grid from every focus node on each D-pad press. +class TVFocusRegistry { + TVFocusRegistry._(); + + static final Map _targets = + {}; + + // routeKey -> row -> order -> target. Rows and orders are SplayTreeMaps so + // predecessor/successor (LEFT/RIGHT) and nearest-row (UP/DOWN) queries are + // O(log n) on the relevant row instead of a full scan. + static final Map>> + _index = {}; + + /// Stable map key for a route (or a shared bucket when the route is null). + static Object _routeKey(ModalRoute? route) => + route == null ? 'noRoute' : identityHashCode(route); + + static void register(TVFocusTarget target) { + // Remove any prior entry for this node (re-registration on rebuild, or a + // node whose row/order changed) before inserting the fresh target. + final previous = _targets[target.focusNode]; + if (previous != null && !identical(previous, target)) { + _removeFromIndex(previous); + } + _targets[target.focusNode] = target; + _insertIntoIndex(target); + } + + static void unregister(FocusNode focusNode) { + final removed = _targets.remove(focusNode); + if (removed != null) { + _removeFromIndex(removed); + } + } + + static void _insertIntoIndex(TVFocusTarget target) { + final orders = _index + .putIfAbsent(_routeKey(target.route), () => SplayTreeMap>()) + .putIfAbsent( + target.row, + () => SplayTreeMap()); + orders[target.order] = target; + } + + static void _removeFromIndex(TVFocusTarget target) { + final rows = _index[_routeKey(target.route)]; + if (rows == null) return; + final orders = rows[target.row]; + if (orders == null) return; + // Only drop the entry if it still points at this exact registration. + if (identical(orders[target.order], target)) { + orders.remove(target.order); + } + if (orders.isEmpty) { + rows.remove(target.row); + } + if (rows.isEmpty) { + _index.remove(_routeKey(target.route)); + } + } + + /// All requestable targets in [row] for the current [route], ordered by + /// `order`. O(log n) row lookup plus O(k) copy of the row. + static List rowTargets({ + required ModalRoute? route, + required double row, + }) { + final rows = _index[_routeKey(route)]; + final orders = rows?[row]; + if (orders == null) return const []; + return orders.values.where((t) => t.isRequestable).toList(growable: false); + } + + /// The target at the exact cell [row]/[order], or null. O(1)–O(log n). + static TVFocusTarget? cellTarget({ + required ModalRoute? route, + required double row, + required double order, + }) { + final target = _index[_routeKey(route)]?[row]?[order]; + if (target == null || !target.isRequestable) return null; + return target; + } + + /// The nearest row value strictly after [row] (down direction), or null. + static double? nextRow({ + required ModalRoute? route, + required double row, + }) { + final rows = _index[_routeKey(route)]; + return rows?.firstKeyAfter(row); + } + + /// The nearest row value strictly before [row] (up direction), or null. + static double? previousRow({ + required ModalRoute? route, + required double row, + }) { + final rows = _index[_routeKey(route)]; + return rows?.lastKeyBefore(row); + } + + /// Ordered row values present for [route], ascending. O(r) copy. + static List rowValues({required ModalRoute? route}) { + final rows = _index[_routeKey(route)]; + if (rows == null) return const []; + return rows.keys.toList(growable: false); + } + + /// Whether any requestable target exists at [row]/[order]. + static bool hasCell({ + required ModalRoute? route, + required double row, + required double order, + }) => + cellTarget(route: route, row: row, order: order) != null; + + /// Removes every registration for a route (used on route disposal, if ever + /// needed). Individual targets unregister themselves on dispose. + static void clearRoute(ModalRoute? route) { + final targetNodes = _targets.entries + .where((e) => identical(_routeKey(e.value.route), _routeKey(route))) + .map((e) => e.key) + .toList(growable: false); + for (final node in targetNodes) { + unregister(node); + } + _index.remove(_routeKey(route)); + } + + 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; + }); + } +} + +/// Marks a decorative subtree (e.g. a page background) as excluded from TV +/// focus. Any [TVFocusTargetRegistrar] inside it is kept out of +/// [TVFocusRegistry], so D-pad navigation never targets it. +/// +/// This is needed because the host navigates via [TVFocusRegistry] rather than +/// the Flutter focus tree, so [ExcludeFocus] alone is not sufficient. +class TVFocusExclusion extends InheritedWidget { + const TVFocusExclusion({super.key, required super.child}); + + static bool isExcluded(BuildContext context) => + context.getElementForInheritedWidgetOfExactType() != + null; + + @override + bool updateShouldNotify(TVFocusExclusion oldWidget) => false; +} + +/// 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 { + // PHASE 3: captured here (not in _register) so the ModalRoute dependency is + // only established in didChangeDependencies, which also re-fires — and + // refreshes this — whenever the enclosing route changes. + ModalRoute? _route; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _route = ModalRoute.of(context); + _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() { + // Decorative subtrees (e.g. page backgrounds) opt out of the TV focus grid. + if (TVFocusExclusion.isExcluded(context)) { + TVFocusRegistry.unregister(widget.focusNode); + return; + } + 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, + route: _route, + // Resolve once at registration so navigation queries are O(1). The + // group widget is recreated on rebuild, but this registrar re-registers + // (constructing a new target) on every rebuild, so the captured group + // stays in sync. + traversalGroup: context.findAncestorWidgetOfExactType(), + ), + ); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/modules/ensemble/lib/framework/tv/tv_focus_scroll.dart b/modules/ensemble/lib/framework/tv/tv_focus_scroll.dart new file mode 100644 index 000000000..e3340fbf3 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_scroll.dart @@ -0,0 +1,421 @@ +import 'package:flutter/material.dart'; + +// ============================================================================= +// TV Focus - Scroll Into View +// ============================================================================= +// Shared scroll-into-view primitives for TV D-pad focus. Both regular +// focusables (box_wrapper.dart) and the focusable scrollbar +// (tv_scrollbar_widget.dart) reveal a newly focused target through the SAME +// axis-restricted rule defined here, so behavior stays consistent and a fix +// only has to be made in one place. + +// TV scroll tuning defaults (single home; overridable via tvOptions in YAML). +// Vertical/threshold/duration are used by the shared helpers below; the +// horizontal/edge values are consumed by box_wrapper's horizontal helpers. +const int kTVScrollAnimationDurationMs = 200; // Scroll animation duration +const double kTVVerticalScrollPadding = 50.0; // Vertical visibility padding +const double kTVScrollThreshold = 2.0; // Min delta to trigger scroll +const double kTVHorizontalScrollPadding = 16.0; // Horizontal visibility padding +const double kTVFixedFocusOffset = 48.0; // Netflix-style fixed position +const double kTVEdgePadding = 8.0; // Edge visibility buffer + +/// Converts a curve name string to a Flutter [Curve]. +/// Supported: easeIn, easeOut, easeInOut, linear, decelerate, ease, +/// fastOutSlowIn, bounceOut, elasticOut. Falls back to [defaultCurve]. +Curve curveFromName(String? curveName, {Curve defaultCurve = Curves.easeOut}) { + switch (curveName?.toLowerCase()) { + case 'easein': + return Curves.easeIn; + case 'easeout': + return Curves.easeOut; + case 'easeinout': + return Curves.easeInOut; + case 'linear': + return Curves.linear; + case 'decelerate': + return Curves.decelerate; + case 'ease': + return Curves.ease; + case 'fastoutslowin': + return Curves.fastOutSlowIn; + case 'bounceout': + return Curves.bounceOut; + case 'elasticout': + return Curves.elasticOut; + default: + return defaultCurve; + } +} + +/// Finds the nearest vertical scrollable ancestor. +ScrollableState? findNearestVerticalScrollable(BuildContext context) { + ScrollableState? scrollable; + + context.visitAncestorElements((element) { + if (element.widget is Scrollable) { + final state = (element as StatefulElement).state; + if (state is ScrollableState) { + final axis = state.axisDirection; + if (axis == AxisDirection.up || axis == AxisDirection.down) { + scrollable = state; + return false; // Stop searching + } + } + } + return true; // Continue searching + }); + + return scrollable; +} + +/// Finds the outermost vertical scrollable ancestor for route-scoped memory. +ScrollableState? findOutermostVerticalScrollable(BuildContext context) { + ScrollableState? scrollable; + + context.visitAncestorElements((element) { + if (element.widget is Scrollable) { + final state = (element as StatefulElement).state; + if (state is ScrollableState) { + final axis = state.axisDirection; + if (axis == AxisDirection.up || axis == AxisDirection.down) { + scrollable = state; + } + } + } + return true; + }); + + return scrollable; +} + +/// Scrolls ONLY [scrollable] so that [itemBox] is fully visible vertically. +/// Unlike Scrollable.ensureVisible(), this does NOT affect horizontal scroll. +/// [verticalPadding] controls the threshold from viewport edges (use larger +/// values when there's a top nav bar that items might hide behind). +/// [animationDurationMs] controls the scroll animation duration in milliseconds. +/// [curve] controls the animation curve (defaults to easeInOut). +void scrollVerticalOnly( + ScrollableState scrollable, + RenderBox itemBox, { + double verticalPadding = kTVVerticalScrollPadding, + int animationDurationMs = kTVScrollAnimationDurationMs, + Curve curve = Curves.easeInOut, +}) { + final scrollableBox = scrollable.context.findRenderObject() as RenderBox?; + if (scrollableBox == null || !scrollableBox.hasSize) return; + + final position = scrollable.position; + + // Get scrollable viewport position relative to screen + final Offset scrollableScreenPos = scrollableBox.localToGlobal(Offset.zero); + final double viewportTop = scrollableScreenPos.dy; + final double viewportBottom = viewportTop + scrollableBox.size.height; + + // Get item position relative to screen + final Offset itemScreenPos = itemBox.localToGlobal(Offset.zero); + final double itemTop = itemScreenPos.dy; + final double itemBottom = itemTop + itemBox.size.height; + + final bool isAboveScreen = itemTop < viewportTop; + final bool isBelowScreen = itemBottom > viewportBottom; + + // If fully visible vertically, no need to scroll + if (!isAboveScreen && !isBelowScreen) { + return; + } + + // Calculate how much to scroll. Use verticalPadding to position the item + // nicely within the viewport, not as a trigger threshold. + double scrollDelta = 0.0; + if (isAboveScreen) { + // Item is above visible area - scroll up (decrease scroll position) + scrollDelta = itemTop - (viewportTop + verticalPadding); + } else if (isBelowScreen) { + scrollDelta = itemBottom - (viewportBottom - verticalPadding); + } + + final double targetScroll = + (position.pixels + scrollDelta).clamp(0.0, position.maxScrollExtent); + + // Only scroll if delta is significant (avoid micro-scrolls) + if ((targetScroll - position.pixels).abs() > kTVScrollThreshold) { + position.animateTo( + targetScroll, + duration: Duration(milliseconds: animationDurationMs), + curve: curve, + ); + } +} + +/// Convenience wrapper: resolves [widgetContext]'s nearest vertical scrollable +/// ancestor and its render box, then reveals it via [scrollVerticalOnly]. +/// Unlike Scrollable.ensureVisible(), this does NOT affect horizontal scroll. +void scrollWidgetIntoView( + BuildContext widgetContext, { + double verticalPadding = kTVVerticalScrollPadding, + int animationDurationMs = kTVScrollAnimationDurationMs, + Curve curve = Curves.easeInOut, +}) { + final scrollable = findNearestVerticalScrollable(widgetContext); + if (scrollable == null) return; + + final itemBox = widgetContext.findRenderObject() as RenderBox?; + if (itemBox == null || !itemBox.hasSize) return; + + scrollVerticalOnly( + scrollable, + itemBox, + verticalPadding: verticalPadding, + animationDurationMs: animationDurationMs, + curve: curve, + ); +} + +/// Whether [scrollable] is a page-snapping pager (a [PageView]). +/// +/// `PageView` gives its [Scrollable] a position that implements [PageMetrics] +/// and, when `pageSnapping` is enabled, applies [PageScrollPhysics] to the +/// scroll physics chain. Such a scrollable only settles on whole pages, so a +/// pixel target derived from a focused item's position is not a valid offset: +/// it either moves the pager to a page the user did not ask for or cancels a +/// page animation that is still running. Page navigation belongs to the pager's +/// own controller (e.g. the carousel's slide handling). +/// +/// A `PageView` with `pageSnapping: false` (e.g. a multi-view carousel) is *not* +/// matched, because arbitrary offsets are legitimate there. +bool isPageSnappingScrollable(ScrollableState scrollable) { + if (scrollable.position is! PageMetrics) return false; + for (ScrollPhysics? physics = scrollable.position.physics; + physics != null; + physics = physics.parent) { + if (physics is PageScrollPhysics) return true; + } + return false; +} + +/// Moves an idle page-snapping pager by whole pages when [target] is outside +/// its viewport. In-flight page animations remain owned by the pager. +Future revealInPageSnappingScrollable( + ScrollableState scrollable, + RenderObject target, { + Duration duration = Duration.zero, + Curve curve = Curves.ease, +}) async { + if (!scrollable.mounted || + !isPageSnappingScrollable(scrollable) || + scrollable.position.isScrollingNotifier.value) { + return; + } + + final targetBox = target as RenderBox?; + final viewportBox = scrollable.context.findRenderObject() as RenderBox?; + final controller = scrollable.widget.controller; + final metrics = scrollable.position as PageMetrics; + if (targetBox == null || + viewportBox == null || + !targetBox.hasSize || + !viewportBox.hasSize || + controller is! PageController || + !controller.hasClients || + !scrollable.position.hasContentDimensions) { + return; + } + + final targetRect = MatrixUtils.transformRect( + targetBox.getTransformTo(viewportBox), + Offset.zero & targetBox.size, + ); + final viewportRect = Offset.zero & viewportBox.size; + if (targetRect.left >= viewportRect.left && + targetRect.right <= viewportRect.right) { + return; + } + + final pageExtent = metrics.viewportDimension * metrics.viewportFraction; + if (pageExtent <= 0) return; + + final overflow = targetRect.right > viewportRect.right + ? targetRect.right - viewportRect.right + : targetRect.left - viewportRect.left; + final pageDelta = overflow > 0 + ? (overflow / pageExtent).ceil() + : -(overflow.abs() / pageExtent).ceil(); + final pageDirection = scrollable.axisDirection == AxisDirection.left ? -1 : 1; + final targetPage = metrics.page!.round() + pageDelta * pageDirection; + await controller.animateToPage( + targetPage, + duration: duration, + curve: curve, + ); +} + +/// Reveals [widgetContext] in every scrollable ancestor using each scrollable's +/// own [ScrollPosition.ensureVisible] with keep-visible alignment policies. +/// +/// Unlike [scrollVerticalOnly], this does NOT derive targets from global +/// coordinates. It therefore respects slivers, pinned/overlay headers, content +/// padding, and nested scrollables, and it moves only the minimum amount +/// required. This is the TV-focus equivalent of Flutter's default traversal +/// reveal ([Scrollable.ensureVisible] with `keepVisibleAtStart`/`End`). +/// +/// Both policies are applied per scrollable: `keepVisibleAtEnd` reveals an item +/// past the trailing edge, `keepVisibleAtStart` reveals an item before the +/// leading edge. Each is a no-op when the item is already visible on that side, +/// so at most one actually animates. Axis direction is handled internally by +/// [ScrollPosition.ensureVisible]. +/// +/// [includeHorizontal]/[includeVertical] let callers skip an axis handled +/// elsewhere (e.g. a host app that manages its own horizontal scrolling). +/// Scrollables on a skipped axis are left untouched but the walk continues to +/// their ancestors. Horizontal page-snapping pagers are skipped for the same +/// reason; their focused descendant is carried forward so an enclosing lane or +/// page can still reveal it against that ancestor's own viewport. +Future ensureWidgetVisible( + BuildContext widgetContext, { + Duration duration = Duration.zero, + Curve curve = Curves.ease, + bool includeHorizontal = true, + bool includeVertical = true, +}) async { + final renderObject = widgetContext.findRenderObject(); + if (renderObject == null || !renderObject.attached) return; + + // Keep the original focused box as the innermost target, while advancing the + // reveal object to each child Scrollable's render object. An outer position + // must resolve its own viewport from its direct descendant, not rediscover + // an inner (possibly skipped) pager viewport from the original focused box. + // This is the same nested-target strategy used by Scrollable.ensureVisible. + RenderObject revealRenderObject = renderObject; + RenderObject? targetRenderObject; + var scrollable = Scrollable.maybeOf(widgetContext); + + while (scrollable != null) { + if (!scrollable.mounted) break; + final axisDirection = scrollable.axisDirection; + final isHorizontal = axisDirection == AxisDirection.left || + axisDirection == AxisDirection.right; + final include = isHorizontal ? includeHorizontal : includeVertical; + + if (include) { + final isPageSnappingPager = + isHorizontal && isPageSnappingScrollable(scrollable); + final position = scrollable.position; + if (isPageSnappingPager) { + await revealInPageSnappingScrollable( + scrollable, + revealRenderObject, + duration: duration, + curve: curve, + ); + } else if (position.hasContentDimensions) { + // Single reveal per scrollable, with the alignment policy chosen from + // the item's current position (like Flutter's directional traversal). + // Calling ensureVisible twice in series starts a second 200ms animation + // after the first settles, which reads as a laggy two-phase scroll. + final policy = + _revealPolicyFor(renderObject, scrollable, targetRenderObject); + if (policy != null) { + await position.ensureVisible( + revealRenderObject, + duration: duration, + curve: curve, + alignmentPolicy: policy, + targetRenderObject: identical(revealRenderObject, renderObject) + ? targetRenderObject + : renderObject, + ); + if (!scrollable.mounted) break; + } + } + } + // A skipped axis still needs to carry the focused descendant outward, so + // the next ancestor reveals that target inside this scrollable's bounds. + targetRenderObject ??= renderObject; + + final scrollableContext = scrollable.context; + final childScrollRenderObject = scrollableContext.findRenderObject(); + if (childScrollRenderObject != null && childScrollRenderObject.attached) { + revealRenderObject = childScrollRenderObject; + } + scrollable = Scrollable.maybeOf(scrollableContext); + } +} + +/// Chooses the single [ScrollPositionAlignmentPolicy] that reveals +/// [target] with the least movement given its position in [scrollable]. +/// +/// Returns `keepVisibleAtEnd` when the item is past the trailing edge, +/// `keepVisibleAtStart` when it is before the leading edge, and `null` when the +/// item is already fully visible on the scrollable's main axis (no scroll +/// needed). Falls back to `keepVisibleAtEnd` if geometry cannot be resolved. +ScrollPositionAlignmentPolicy? _revealPolicyFor( + RenderObject target, + ScrollableState scrollable, + RenderObject? targetRenderObject, +) { + final targetBox = target as RenderBox?; + final scrollableBox = scrollable.context.findRenderObject() as RenderBox?; + if (targetBox == null || + scrollableBox == null || + !targetBox.hasSize || + !scrollableBox.hasSize) { + return ScrollPositionAlignmentPolicy.keepVisibleAtEnd; + } + + final horizontal = scrollable.axisDirection == AxisDirection.left || + scrollable.axisDirection == AxisDirection.right; + + // Position of the item's leading/trailing edge in the scrollable's viewport + // coordinate space. + final Offset itemTopLeft = targetBox.localToGlobal(Offset.zero); + final Offset viewportTopLeft = scrollableBox.localToGlobal(Offset.zero); + final double itemStart = horizontal ? itemTopLeft.dx : itemTopLeft.dy; + final double itemEnd = itemStart + + (horizontal ? targetBox.size.width : targetBox.size.height); + final double viewportStart = + horizontal ? viewportTopLeft.dx : viewportTopLeft.dy; + final double viewportEnd = + viewportStart + (horizontal ? scrollableBox.size.width : scrollableBox.size.height); + + if (itemEnd > viewportEnd) { + return ScrollPositionAlignmentPolicy.keepVisibleAtEnd; + } + if (itemStart < viewportStart) { + return ScrollPositionAlignmentPolicy.keepVisibleAtStart; + } + return null; +} + + +// ============================================================================= +// TV Focus - Active Vertical Scrollable Memory (route-scoped) +// ============================================================================= + +final Map _activeVerticalScrollables = {}; + +Object _activeScrollableRouteKey(Route? route) => + route == null ? 'noRoute' : identityHashCode(route); + +void rememberActiveVerticalScrollable( + Route? route, ScrollableState scrollable) { + _activeVerticalScrollables[_activeScrollableRouteKey(route)] = scrollable; +} + +ScrollableState? activeVerticalScrollable(Route? route) => + _activeVerticalScrollables[_activeScrollableRouteKey(route)]; + +void clearActiveVerticalScrollableForRoute(Route? route) { + _activeVerticalScrollables.remove(_activeScrollableRouteKey(route)); +} + +/// Resolves [context]'s scrollable ancestry and remembers the outermost +/// vertical scrollable for the current route so `resetScrollOnFocus` can +/// target it. No-op when there is no vertical scrollable ancestor. +void rememberActiveVerticalScrollableForContext(BuildContext context) { + final nearest = findNearestVerticalScrollable(context); + if (nearest == null) return; + rememberActiveVerticalScrollable( + ModalRoute.of(context), + findOutermostVerticalScrollable(context) ?? nearest, + ); +} 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..6142e6870 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_widget.dart @@ -0,0 +1,483 @@ +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_registry.dart'; +import 'package:ensemble/framework/tv/tv_focus_scroll.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 { + // ───────────────────────────────────────────────────────────────────────── + // Row Position Memory (opt-in via tvOptions.rememberRowPosition) + // ───────────────────────────────────────────────────────────────────────── + // Remembers the last focused column (order) per row so vertical navigation + // returns to it. Enabled per-widget by `rememberRowPosition` — NOT by + // focusGroup. Scoped per screen: the outer map is keyed by the ModalRoute + // instance, so different screens (and different visits of the same screen) + // never share memory. Within a route it is keyed by "${focusGroup ?? ''}_$row" + // so multiple independent regions on one screen stay separate when they set a + // focusGroup — and it still works with no focusGroup at all. + // ───────────────────────────────────────────────────────────────────────── + static final Map> _rowOrderMemory = {}; + + /// Per-screen namespace (route instance identity). Base [Route] type so the + /// observer (plain Route on pop) and nav code (ModalRoute) key the same bucket. + static Object _routeKey(Route? route) => + route == null ? 'noRoute' : identityHashCode(route); + + /// Creates a within-route memory key for the given focusGroup and row. + static String _memoryKey(String? focusGroup, double row) { + return '${focusGroup ?? ''}_$row'; + } + + /// Saves the current order for a row (called when leaving the row). + static void _rememberOrder(Route? route, String? focusGroup, + double row, double order) { + final bucket = _rowOrderMemory.putIfAbsent(_routeKey(route), () => {}); + bucket[_memoryKey(focusGroup, row)] = order; + } + + /// Retrieves the remembered order for a row, or null if not remembered. + static double? _recallOrder( + Route? route, String? focusGroup, double row) { + return _rowOrderMemory[_routeKey(route)]?[_memoryKey(focusGroup, row)]; + } + + /// Public save, called on focus-gain so memory stays fresh on all entry paths + /// (taps, edges, requestFocusAt/ByEdge, carousel), not just D-pad. + static void rememberOrder( + Route? route, String? focusGroup, double row, double order) { + _rememberOrder(route, focusGroup, row, order); + } + + /// Clears a screen's memory when its route leaves the stack (via + /// [TVFocusRouteObserver]), so the map doesn't grow unbounded. + static void clearRowMemoryForRoute(Route? route) { + _rowOrderMemory.remove(_routeKey(route)); + } + + /// Finds the index of the item with the nearest order value to the target. + /// Used when the remembered order no longer exists (item was removed). + static int _findNearestOrderIndex( + List row, + double targetOrder, + ) { + if (row.isEmpty) return 0; + + int nearestIdx = 0; + double nearestDiff = (row[0].order.order - targetOrder).abs(); + + for (int i = 1; i < row.length; i++) { + final diff = (row[i].order.order - targetOrder).abs(); + if (diff < nearestDiff) { + nearestDiff = diff; + nearestIdx = i; + } + } + return nearestIdx; + } + + 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 registeredFocusNode = primaryFocusNode; + + // Case 1: the child exposes its own requestable node (e.g. an InkWell's + // FocusNode passed via primaryFocusNode). Register that node directly and + // keep the wrapping FocusScope anonymous. (Unchanged legacy behavior.) + if (registeredFocusNode != null) { + 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: FocusTraversalOrder( + order: focusOrder, + // Use FocusScope instead of Focus so this node becomes the PARENT of + // the child's focus node, letting key events bubble up through the + // handler instead of bypassing a sibling. + child: FocusScope( + onKeyEvent: _handleScopeKeyEvent, + child: child, + ), + ), + ); + } + + // Case 2: the child is a passive wrapper with no requestable node of its own + // (e.g. bracket tiles/tabs whose real focusable leaf lives deeper in the + // child). Wrap in an ANONYMOUS FocusScope and do NOT register it: the child's + // actual leaf is discovered by the live focus-tree scan in + // TVFocusOrderNode.collectInScope. Registering the scope node instead would + // make navigation request focus on an empty scope, which focuses the scope + // itself and shows no ring. + return FocusTraversalOrder( + order: focusOrder, + // FocusScope (not Focus) so this node parents the child's focus node and + // D-pad key events bubble up to [_handleScopeKeyEvent]. + child: FocusScope( + onKeyEvent: _handleScopeKeyEvent, + child: child, + ), + ); + } + + /// Handles D-pad key events arriving at the wrapping FocusScope. + /// + /// [node] is the enclosing scope node the event bubbled up to; navigation + /// uses it for ancestor/route lookups while this widget's [focusOrder] + /// identifies the current grid position. + KeyEventResult _handleScopeKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent || event is KeyRepeatEvent) { + // 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(node, yOffset: 1)) return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_moveFocus(node, yOffset: -1)) return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_moveFocus(node, xOffset: 1)) return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_moveFocus(node, xOffset: -1)) return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + + /// Move focus in the specified direction. + /// Returns true if focus was moved, false if at boundary. + bool _moveFocus( + 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; + // PHASE 1: unified collection (was registry + descendants scan inlined here). + final inScopeByOrder = TVFocusOrderNode.collectInScope( + route: route, + traversalGroup: focusTraversalGroup, + focusGroup: currentFocusGroup, + ); + + 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., a native tab or + // screen hosting this Ensemble view) + // so users can navigate back to native content from Ensemble content + if (yOffset == -1 && y == 0) { + if (topEdgeHandler != null) { + 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); + + // Get the target row's actual row value + final targetRowValue = grid[newY].first.order.row; + + // Save keyed on the SOURCE row; restore keyed on the TARGET row (its own + // rememberRowPosition + focusGroup) — so entering a remembering row from + // an ungrouped/other row (e.g. a TabBar) still restores its column. + if (focusOrder.rememberRowPosition) { + _rememberOrder( + route, currentFocusGroup, focusOrder.row, focusOrder.order); + } + + final targetOrderMeta = grid[newY].first.order; + if (targetOrderMeta.rememberRowPosition) { + final rememberedOrder = + _recallOrder(route, targetOrderMeta.focusGroup, targetRowValue); + if (rememberedOrder != null) { + // Try to find item with remembered order + final rememberedIndex = grid[newY].indexWhere( + (node) => node.order.order == rememberedOrder, + ); + if (rememberedIndex != -1) { + // Found the remembered item + newX = rememberedIndex; + } else { + // Remembered order no longer exists, find nearest order + newX = _findNearestOrderIndex(grid[newY], rememberedOrder); + } + } else { + // No memory for this row - use entry point or first item + final entryPointIndex = _findRowEntryPoint(grid[newY]); + newX = entryPointIndex != -1 ? entryPointIndex : 0; + } + } else { + // Default (grid) behavior: entry point → preserve current column → clamp. + 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; + } + } +} + +/// Frees [TVFocusWidget]'s per-route row-position memory when a screen leaves +/// the stack. Cleared at the route level (not per widget) so item-template +/// rebuilds don't wipe live memory. Registered in [EnsembleRouteObserver]. +class TVFocusRouteObserver extends NavigatorObserver { + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + TVFocusWidget.clearRowMemoryForRoute(route); + clearActiveVerticalScrollableForRoute(route); + } + + @override + void didRemove(Route route, Route? previousRoute) { + super.didRemove(route, previousRoute); + TVFocusWidget.clearRowMemoryForRoute(route); + clearActiveVerticalScrollableForRoute(route); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + if (oldRoute != null) { + TVFocusWidget.clearRowMemoryForRoute(oldRoute); + clearActiveVerticalScrollableForRoute(oldRoute); + } + } +} 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..4aa41f5e2 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_scrollbar_widget.dart @@ -0,0 +1,481 @@ +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_scroll.dart'; +import 'package:ensemble/framework/tv/tv_focus_widget.dart'; +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 TVScrollbarFallbackFocusConfig { + const TVScrollbarFallbackFocusConfig({ + required this.row, + required this.order, + this.focusGroup, + this.isRowEntryPoint = false, + }); + + final double row; + final double order; + final String? focusGroup; + final bool isRowEntryPoint; +} + +class TVScrollbarWidget extends StatefulWidget { + const TVScrollbarWidget({ + super.key, + required this.scrollController, + required this.options, + this.fallbackFocus, + this.verticalScrollPadding, + this.scrollAnimationDuration, + this.scrollAnimationCurve, + }); + + /// ScrollController from the scrollable content (ListView/Column) + final ScrollController scrollController; + + /// Scrollbar styling options from YAML + final TVScrollbarOptionsComposite options; + + /// Optional focus coordinate used when the owning ListView has no focusable + /// descendants. When omitted, focus reaches the scrollbar only via edge + /// handoff from a focused content item. + final TVScrollbarFallbackFocusConfig? fallbackFocus; + + /// Scroll-into-view tuning inherited from the owning ListView's tvOptions so + /// the scrollbar reveals itself with the SAME padding/duration/curve as the + /// content items beside it. Null values fall back to the shared TV defaults. + final double? verticalScrollPadding; + final int? scrollAnimationDuration; + final String? scrollAnimationCurve; + + @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; + + // `scrollbarOptions.autofocus` is claimed explicitly (see + // [_maybeAutofocusScrollbar]) because a competing autofocus elsewhere on the + // screen (e.g. a BackArrow fallback) makes Flutter discard the scrollbar's + // own autofocus request. Only honored during the initial settling window so + // content that becomes scrollable much later cannot yank focus away. + bool _didAutofocusScrollbar = false; + static const Duration _autofocusWindow = Duration(milliseconds: 1500); + final DateTime _createdAt = DateTime.now(); + + // Thumb offset/height are pushed here on scroll so only the thumb rebuilds + // (via ValueListenableBuilder) instead of the whole scrollbar subtree. + final ValueNotifier<({double offset, double height})> _thumbVN = + ValueNotifier((offset: 0.0, height: 0.0)); + + @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 didUpdateWidget(covariant TVScrollbarWidget oldWidget) { + super.didUpdateWidget(oldWidget); + // The ListView resolves `fallbackFocus` asynchronously, after the first + // frame. Retry the entry autofocus once it becomes a registered focus + // target (see [_maybeAutofocusScrollbar]). + if (widget.fallbackFocus != null && oldWidget.fallbackFocus == null) { + _maybeAutofocusScrollbar(); + } + } + + /// Refresh visibility when the owning scrollable's content dimensions change. + /// A [ScrollController] only notifies listeners for scroll-position changes, + /// not for a new maxScrollExtent produced by asynchronous content. + void updateForScrollMetrics() { + if (!mounted || + !widget.scrollController.hasClients || + !widget.scrollController.position.hasContentDimensions) { + return; + } + _refreshVisibility(markInitialized: true); + } + + bool get _isFallbackFocusTarget => widget.fallbackFocus != null; + + ScrollableState? _owningScrollable() { + if (!widget.scrollController.hasClients) return null; + final storageContext = + widget.scrollController.position.context.storageContext; + return Scrollable.maybeOf(storageContext); + } + + bool get _canScrollUp { + if (!widget.scrollController.hasClients) return false; + final position = widget.scrollController.position; + return position.pixels > position.minScrollExtent; + } + + bool get _canScrollDown { + if (!widget.scrollController.hasClients) return false; + final position = widget.scrollController.position; + return position.pixels < position.maxScrollExtent; + } + + @override + void dispose() { + widget.scrollController.removeListener(_onScrollChange); + _focusNode.dispose(); + _thumbVN.dispose(); + super.dispose(); + } + + void _onScrollChange() { + if (!mounted || !widget.scrollController.hasClients) return; + _refreshVisibility(); + } + + /// Updates the thumb and, if visibility should change, rebuilds. Shared by + /// [_onScrollChange] (scroll-position updates) and [updateForScrollMetrics] + /// (content-dimension updates) so their rebuild condition can't drift apart. + void _refreshVisibility({bool markInitialized = false}) { + final wasInitialized = _isInitialized; + final wasScrollable = _isScrollable; + if (markInitialized) _isInitialized = true; + _updateThumbPosition(); // updates _thumbVN + _isScrollable (no setState) + // Full rebuild only when visibility toggles; thumb moves are handled by the + // ValueListenableBuilder around the thumb. + if ((markInitialized && !wasInitialized) || _isScrollable != wasScrollable) { + setState(() {}); + } + } + + 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; + _thumbVN.value = (offset: 0.0, height: 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); + _thumbVN.value = (offset: _thumbOffset, height: _thumbHeight); + _maybeAutofocusScrollbar(); + } + + /// When a scrollbar is configured with `autofocus` and content starts to + /// overflow, claim focus once after the frame. If the content never overflows + /// the scrollbar never renders, so a screen-provided fallback (e.g. a + /// BackArrow autofocus) keeps focus instead. + void _maybeAutofocusScrollbar() { + if (_didAutofocusScrollbar || + !widget.options.autofocus || + !_isScrollable) { + return; + } + if (DateTime.now().difference(_createdAt) > _autofocusWindow) { + _didAutofocusScrollbar = true; + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + _didAutofocusScrollbar || + !_isScrollable || + _focusNode.hasFocus) { + if (mounted && _focusNode.hasFocus) { + _didAutofocusScrollbar = true; + } + return; + } + // Only autofocus when the scrollbar is a real TV focus target (the + // ListView had no focusable content and registered it with the page + // focus grid). Otherwise it is reached via edge handoff, and focusing + // it directly would bypass the grid and trap UP/DOWN. + if (!_isFallbackFocusTarget) return; + _didAutofocusScrollbar = true; + _focusNode.requestFocus(); + }); + } + + 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 + final scrollbar = 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 && event is! KeyRepeatEvent)) { + return KeyEventResult.ignored; + } + + // Handle UP/DOWN for manual scrolling + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_isFallbackFocusTarget && !_canScrollDown) { + return KeyEventResult.ignored; + } + _scrollDown(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_isFallbackFocusTarget && !_canScrollUp) { + return KeyEventResult.ignored; + } + _scrollUp(); + return KeyEventResult.handled; + } + + if (_isFallbackFocusTarget && + (event.logicalKey == LogicalKeyboardKey.arrowLeft || + event.logicalKey == LogicalKeyboardKey.arrowRight)) { + return KeyEventResult.handled; + } + + // LEFT/RIGHT are intentionally not consumed: leaving them unhandled + // lets the focus system move focus back to the content (the + // scrollbar sits beside the list, so horizontal nav naturally exits + // it). UP/DOWN above are the only keys this scrollbar handles. + return KeyEventResult.ignored; + }, + child: InkWell( + focusNode: _focusNode, + autofocus: widget.options.autofocus, + focusColor: widget.options.focusHighlightColor, + onTap: () {}, + onFocusChange: (hasFocus) { + if (mounted) { + setState(() { + _isFocused = hasFocus; + }); + // Reveal the scrollbar in its enclosing scrollable when it + // gains focus (same scroll-into-view rule as other focusables). + // Re-check `_isFocused` at callback time in case focus has + // already moved away before the frame completes. + if (hasFocus) { + final owningScrollable = _owningScrollable(); + final activeScrollable = owningScrollable == null + ? findOutermostVerticalScrollable(context) + : findOutermostVerticalScrollable( + widget.scrollController.position.context + .storageContext) ?? + owningScrollable; + if (activeScrollable != null) { + rememberActiveVerticalScrollable( + ModalRoute.of(context), activeScrollable); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && context.mounted && _isFocused) { + scrollWidgetIntoView( + context, + verticalPadding: widget.verticalScrollPadding ?? + kTVVerticalScrollPadding, + animationDurationMs: widget.scrollAnimationDuration ?? + kTVScrollAnimationDurationMs, + curve: curveFromName(widget.scrollAnimationCurve, + defaultCurve: Curves.easeInOut), + ); + } + }); + } + } + }, + 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: [ + // Only the thumb rebuilds on scroll (not the whole subtree). + ValueListenableBuilder<({double offset, double height})>( + valueListenable: _thumbVN, + builder: (context, thumb, _) { + return AnimatedPositioned( + duration: const Duration(milliseconds: 150), + left: 0, + top: thumb.offset, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: _isFocused + ? widget.options.focusedWidth + : widget.options.width, + height: thumb.height, + decoration: BoxDecoration( + color: _isFocused + ? widget.options.focusedColor + : widget.options.color, + borderRadius: + BorderRadius.circular(widget.options.radius), + ), + ), + ); + }, + ), + ], + ), + ), + ), + ); + }, + ); + + final fallbackFocus = widget.fallbackFocus; + if (fallbackFocus == null) { + return scrollbar; + } + + final externalProvider = TVFocusProviderScope.maybeOf(context); + if (externalProvider != null) { + return externalProvider.wrapFocusable( + row: fallbackFocus.row + externalProvider.rowOffset, + order: fallbackFocus.order + externalProvider.orderOffset, + isRowEntryPoint: fallbackFocus.isRowEntryPoint, + lockHorizontalNavigation: true, + focusGroup: fallbackFocus.focusGroup, + primaryFocusNode: _focusNode, + child: scrollbar, + ); + } + + return TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + fallbackFocus.row, + order: fallbackFocus.order, + isRowEntryPoint: fallbackFocus.isRowEntryPoint, + lockHorizontalNavigation: true, + focusGroup: fallbackFocus.focusGroup, + ), + primaryFocusNode: _focusNode, + child: scrollbar, + ); + } +} diff --git a/modules/ensemble/lib/framework/tv/tv_tooltip_registry.dart b/modules/ensemble/lib/framework/tv/tv_tooltip_registry.dart new file mode 100644 index 000000000..279f490ee --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_tooltip_registry.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; + +/// Handle for a single open TV tooltip. +class TVTooltipEntry { + TVTooltipEntry({required this.close, this.route}); + + /// Closes the tooltip and restores focus to its anchor. + /// + /// Returns true when this call initiated the close (false if the tooltip was + /// already closing or not open). + final bool Function() close; + + /// Route the tooltip's anchor belongs to. Used to scope dismissal so an + /// action on one screen cannot close a tooltip belonging to another. + final Route? route; +} + +/// Tracks open TV tooltips so they can be dismissed programmatically. +/// +/// A TV tooltip is rendered through an [OverlayPortal], not a route, so +/// it cannot be popped like a dialog or bottom sheet. This registry is the +/// tooltip equivalent of the dialog/bottom-sheet dismiss helpers and gives a +/// `dismissTooltip` action a stable target. +class TVTooltipRegistry { + TVTooltipRegistry._(); + + static final List _open = []; + + static void register(TVTooltipEntry entry) { + _open.remove(entry); + _open.add(entry); + } + + static void unregister(TVTooltipEntry entry) { + _open.remove(entry); + } + + /// Closes the tooltip registered for [route]. When no route is given, the + /// most recently opened tooltip is closed. When a route is given but does not + /// match any tooltip, a single open tooltip is still closed (unambiguous), + /// otherwise no tooltip is touched. Tooltips whose route could not be + /// resolved are treated as matching any route. + static bool dismiss({Route? route}) { + if (_open.isEmpty) { + return false; + } + TVTooltipEntry? target; + if (route == null) { + target = _open.last; + } else { + for (final entry in _open.reversed) { + if (entry.route == null || entry.route == route) { + target = entry; + break; + } + } + if (target == null && _open.length == 1) { + target = _open.single; + } + } + if (target == null) { + return false; + } + return target.close(); + } +} diff --git a/modules/ensemble/lib/framework/view/page.dart b/modules/ensemble/lib/framework/view/page.dart index 218b7387d..b76b48b85 100644 --- a/modules/ensemble/lib/framework/view/page.dart +++ b/modules/ensemble/lib/framework/view/page.dart @@ -25,6 +25,10 @@ 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:ensemble/framework/tv/tv_focus_registry.dart'; import 'package:flutter/material.dart'; class SinglePageController extends WidgetController { @@ -86,7 +90,10 @@ class Page extends StatefulWidget { } class PageState extends State - with AutomaticKeepAliveClientMixin, RouteAware, WidgetsBindingObserver { + with + AutomaticKeepAliveClientMixin, + RouteAware, + WidgetsBindingObserver { late Widget rootWidget; late ScopeManager _scopeManager; Widget? footerWidget; @@ -104,6 +111,12 @@ class PageState extends State Timer? _titleBarHeightPollTimer; Timer? _headerVisibilityPollTimer; + // An enabled fixed TV header without a visibility expression expands while + // focus is in the AppBar and collapses when focus moves to the body. + final GlobalKey _headerKey = GlobalKey(debugLabel: 'ensemble_header'); + bool _headerExpanded = false; + bool _tvFocusCollapseEnabled = false; + // Last dispatched device metrics. We only want to notify bindings when the // actual screen dimensions/orientation change, not when the keyboard opens. int? _lastDeviceWidth; @@ -444,17 +457,31 @@ class PageState extends State _setupPeriodicStorageCheck(); } - // Set up collapsibleHeader listeners if enabled + // Visibility expressions keep their legacy behavior. Focus-driven collapse + // is reserved for enabled, fixed TV headers that do not define `visible`. if (widget._pageModel.headerModel != null) { - final _headerStyles = EnsembleThemeManager().getRuntimeStyles( + final headerStyles = EnsembleThemeManager().getRuntimeStyles( _scopeManager.dataContext, widget._pageModel.headerModel!); - final collapsible = _headerStyles?['collapsibleHeader']; + final collapsible = headerStyles?['collapsibleHeader']; final enabled = Utils.getBool(collapsible?['enabled'], fallback: false); - if (enabled) { + final hasVisibilityExpression = collapsible?['visible'] != null; + final isScrollable = + widget._pageModel.runtimeStyles?['scrollableView'] == true; + _tvFocusCollapseEnabled = + Device().isTV && enabled && !hasVisibilityExpression && !isScrollable; + + if (enabled && hasVisibilityExpression) { _initializeLastKnownHeaderVisible(); _listenToHeaderVisibilityChanges(); _setupPeriodicVisibilityCheck(); } + if (_tvFocusCollapseEnabled) { + // Start expanded so the first header focus target is laid out at its + // final size. The focus listener will collapse it as soon as focus + // enters the body. + _headerExpanded = true; + FocusManager.instance.addListener(_onPrimaryFocusChanged); + } } // Adding a listener for [viewGroupNotifier] so we can execute @@ -518,9 +545,11 @@ class PageState extends State /// fixed AppBar dynamic _buildAppBar(HeaderModel headerModel, {required bool scrollableView, bool? showNavigationIcon}) { - Widget? titleWidget; - + final evaluatedHeader = EnsembleThemeManager() + .getRuntimeStyles(_scopeManager.dataContext, headerModel); + final collapsible = evaluatedHeader?['collapsibleHeader']; + Widget? titleWidget; if (headerModel.titleWidget != null) { titleWidget = _scopeManager.buildWidget(headerModel.titleWidget!); } @@ -540,9 +569,10 @@ class PageState extends State if (headerModel.leadingWidget != null) { leadingWidget = _scopeManager.buildWidget(headerModel.leadingWidget!); } - - final evaluatedHeader = EnsembleThemeManager() - .getRuntimeStyles(_scopeManager.dataContext, headerModel); + final double? leadingWidth = Utils.optionalInt( + _scopeManager.dataContext.eval(evaluatedHeader?['leadingWidth']), + min: 0, + )?.toDouble(); bool centerTitle = Utils.getBool(evaluatedHeader?['centerTitle'], fallback: true); @@ -592,11 +622,16 @@ class PageState extends State if (flexibleMinHeight != null && flexibleMinHeight < baseTitleBarHeight) { flexibleMinHeight = null; } + if (flexibleMaxHeight != null && + flexibleMinHeight != null && + flexibleMaxHeight < flexibleMinHeight) { + flexibleMaxHeight = null; + } - // Collapsible header support - final collapsible = evaluatedHeader?['collapsibleHeader']; + // Legacy expression-driven fixed-header collapse. final bool collapsibleEnabled = - Utils.getBool(collapsible?['enabled'], fallback: false); + Utils.getBool(collapsible?['enabled'], fallback: false) && + collapsible?['visible'] != null; bool isHeaderVisible = true; if (collapsibleEnabled) { final visibleExpr = collapsible?['visible']; @@ -609,9 +644,9 @@ class PageState extends State final titleBarHeight = isHeaderVisible ? baseTitleBarHeight : 0.0; if (scrollableView) { - return AnimatedAppBar( scrollController: externalScrollController!, + return AnimatedAppBar( key: _headerKey, scrollController: externalScrollController!, automaticallyImplyLeading: - leadingWidget == null && showNavigationIcon != false, + leadingWidget == null && showNavigationIcon != false, leadingWidget: leadingWidget, titleWidget: titleWidget, centerTitle: centerTitle, @@ -637,18 +672,52 @@ class PageState extends State floating: scrollMode == ScrollMode.floating, pinned: scrollMode == ScrollMode.pinned, ); - } else if (collapsibleEnabled) { + } else if (collapsibleEnabled || _tvFocusCollapseEnabled) { + final compactHeight = flexibleMinHeight ?? baseTitleBarHeight; + final expandedHeight = flexibleMaxHeight ?? compactHeight; + final fixedHeaderHeight = _tvFocusCollapseEnabled + ? (_headerExpanded ? expandedHeight : compactHeight) + : titleBarHeight; + + // Cross-swap the two header slots based on focus: while expanded (focus + // in the header) the compact title shifts out of the bar and the + // flexibleBackground sits in place; once focus moves to the body the + // title shifts back in and the background shifts out of view. Mirrors the + // scrollable mobile header, driven by `_headerExpanded` instead of scroll. + Widget? animatedTitle = titleWidget; + if (_tvFocusCollapseEnabled && animationEnabled && titleWidget != null) { + final slotDuration = Duration(milliseconds: duration ?? 200); + final slotCurve = curve ?? Curves.easeInOut; + animatedTitle = switch (animationType) { + AnimationType.fade => AnimatedOpacity( + opacity: _headerExpanded ? 0.0 : 1.0, + duration: slotDuration, + curve: slotCurve, + child: titleWidget, + ), + AnimationType.drop => AnimatedSlide( + offset: _headerExpanded ? const Offset(0, -2) : Offset.zero, + duration: slotDuration, + curve: slotCurve, + child: titleWidget, + ), + _ => titleWidget, + }; + } + return PreferredSize( - preferredSize: Size.fromHeight(titleBarHeight), + preferredSize: Size.fromHeight(fixedHeaderHeight), child: AnimatedContainer( - height: titleBarHeight, + height: fixedHeaderHeight, duration: Duration(milliseconds: duration ?? 200), curve: curve ?? Curves.easeInOut, child: AppBar( + key: _headerKey, automaticallyImplyLeading: leadingWidget == null && showNavigationIcon != false, leading: leadingWidget, - title: titleWidget, + leadingWidth: leadingWidth, + title: animatedTitle, centerTitle: centerTitle, backgroundColor: backgroundColor, surfaceTintColor: surfaceTintColor, @@ -658,16 +727,30 @@ class PageState extends State elevation: elevation, shadowColor: shadowColor, - toolbarHeight: titleBarHeight, - flexibleSpace: backgroundWidget, + // TV keeps the toolbar/title at its compact height while the + // surrounding AnimatedContainer grows flexible content. Legacy + // visibility-driven headers retain their title-bar height. + toolbarHeight: + _tvFocusCollapseEnabled ? compactHeight : titleBarHeight, + flexibleSpace: _buildFixedHeaderFlexibleSpace( + backgroundWidget, + enabled: _tvFocusCollapseEnabled, + visible: _headerExpanded, + duration: Duration(milliseconds: duration ?? 200), + curve: curve ?? Curves.easeInOut, + expandedHeight: expandedHeight, + animationType: animationType, + ), ), ), ); } else { return AppBar( + key: _headerKey, automaticallyImplyLeading: leadingWidget == null && showNavigationIcon != false, leading: leadingWidget, + leadingWidth: leadingWidth, title: titleWidget, centerTitle: centerTitle, backgroundColor: backgroundColor, @@ -736,15 +819,28 @@ class PageState extends State LinearGradient? backgroundGradient = Utils.getBackgroundGradient( widget._pageModel.runtimeStyles?['backgroundGradient']); - Color? backgroundColor = Utils.getColor(_scopeManager.dataContext - .eval(widget._pageModel.runtimeStyles?['backgroundColor'])); + Color? backgroundColor = Utils.getColor( + _scopeManager.dataContext.eval( + widget._pageModel.runtimeStyles?['backgroundColor'])); // if we have a background image, set the background color to transparent // since our image is outside the Scaffold dynamic evaluatedBackgroundImg = _scopeManager.dataContext .eval(widget._pageModel.runtimeStyles?['backgroundImage']); BackgroundImage? backgroundImage = Utils.getBackgroundImage(evaluatedBackgroundImg); - if (backgroundImage != null || backgroundGradient != null) { + + // Optional TV widget background (`View.styles.background`). + // It is the only background layer when configured. + // The widget background is a TV-only layer. Non-TV pages retain their + // existing backgroundImage/backgroundColor behavior unchanged. + final Widget? customBackground = + Device().isTV ? _buildPageBackground() : null; + + if (customBackground != null) { + backgroundImage = null; + backgroundGradient = null; + backgroundColor = Colors.transparent; + } else if (backgroundImage != null || backgroundGradient != null) { backgroundColor = Colors.transparent; } @@ -779,6 +875,30 @@ class PageState extends State ); } + // Scaffold reads an app bar's preferred size only while it builds. Put the + // focus-collapsible fixed header in the body instead so AnimatedContainer + // can relayout the adjacent content on every animation frame without a + // page-level rebuild. + final inlineTvHeader = + _tvFocusCollapseEnabled && !isScrollableView && fixedAppBar != null; + final pageBody = FooterLayout( + body: isScrollableView + ? buildScrollablePageContent(hasDrawer) + : buildFixedPageContent(fixedAppBar != null), + footer: footerWidget, + ); + final scaffoldBody = inlineTvHeader + ? Column( + children: [ + // SafeArea supplies the status-bar inset that Scaffold normally + // reserves for appBar, while removing it from the inline AppBar + // so its animated height remains the configured header height. + SafeArea(bottom: false, child: fixedAppBar), + Expanded(child: pageBody), + ], + ) + : pageBody; + Widget rtn = DataScopeWidget( scopeManager: _scopeManager, child: Unfocus( @@ -790,14 +910,9 @@ class PageState extends State // the entire screen including the Safe Area backgroundColor: backgroundColor, - // appBar is inside CustomScrollView if defined - appBar: fixedAppBar, - body: FooterLayout( - body: isScrollableView - ? buildScrollablePageContent(hasDrawer) - : buildFixedPageContent(fixedAppBar != null), - footer: footerWidget, - ), + // Scrollable and legacy fixed headers remain Scaffold app bars. + appBar: inlineTvHeader ? null : fixedAppBar, + body: scaffoldBody, bottomNavigationBar: _bottomNavBar, drawer: _drawer, endDrawer: _endDrawer, @@ -816,10 +931,22 @@ 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 + Widget content; if (backgroundImage != null) { - return Stack( + content = Stack( children: [ Positioned.fill( child: backgroundImage.getImageAsWidget(_scopeManager), @@ -828,13 +955,51 @@ class PageState extends State ], ); } else if (backgroundGradient != null) { - return Scaffold( + content = Scaffold( resizeToAvoidBottomInset: false, + // Let a widget-based background show through the gradient's + // transparent regions. Without one, keep the default background. + backgroundColor: + customBackground != null ? Colors.transparent : null, body: Container( decoration: BoxDecoration(gradient: backgroundGradient), child: rtn)); + } else { + content = rtn; } - return rtn; + + if (customBackground != null) { + content = Stack( + children: [ + Positioned.fill(child: customBackground), + content, + ], + ); + } + return content; + } + + /// Build the optional widget-based page background (`View.styles.background`). + /// Backgrounds are decorative: they must never take D-pad focus or intercept + /// remote/tap input on TV, so they are wrapped in [ExcludeFocus] + + /// [IgnorePointer]. They are also excluded from the host's [TVFocusRegistry] + /// grid via [TVFocusExclusion], and wrapped in a [DataScopeWidget] since they + /// sit outside the body's scope and still need expression bindings. + Widget? _buildPageBackground() { + final WidgetModel? backgroundModel = widget._pageModel.backgroundWidget; + if (backgroundModel == null) { + return null; + } + return TVFocusExclusion( + child: ExcludeFocus( + child: IgnorePointer( + child: DataScopeWidget( + scopeManager: _scopeManager, + child: _scopeManager.buildWidget(backgroundModel), + ), + ), + ), + ); } /// determine if we should wraps the body in a SafeArea or not @@ -958,12 +1123,12 @@ class PageState extends State // show a divider between the NavigationRail and the content Color? borderColor = Utils.getColor(widget._pageModel.menu!.runtimeStyles?['borderColor']); - int? borderWidth = Utils.optionalInt( + double? borderWidth = Utils.optionalDouble( widget._pageModel.menu!.runtimeStyles?['borderWidth']); if (borderColor != null || borderWidth != null) { content.add(VerticalDivider( - thickness: (borderWidth ?? 1).toDouble(), - width: (borderWidth ?? 1).toDouble(), + thickness: borderWidth ?? 1, + width: borderWidth ?? 1, color: borderColor)); } @@ -1175,12 +1340,103 @@ class PageState extends State }); } + void _onPrimaryFocusChanged() { + if (!_tvFocusCollapseEnabled || !mounted) return; + final expanded = _isPrimaryFocusInHeader(); + if (expanded != _headerExpanded) { + setState(() => _headerExpanded = expanded); + } + } + + bool _isPrimaryFocusInHeader() { + final primaryContext = FocusManager.instance.primaryFocus?.context; + if (primaryContext == null || + ModalRoute.of(primaryContext) != ModalRoute.of(context)) { + return false; + } + final fixedHeader = primaryContext.findAncestorWidgetOfExactType(); + return fixedHeader?.key == _headerKey; + } + + Widget? _buildFixedHeaderFlexibleSpace( + Widget? backgroundWidget, { + required bool enabled, + required bool visible, + required Duration duration, + required Curve curve, + required double expandedHeight, + AnimationType? animationType, + }) { + if (backgroundWidget == null || !enabled) return backgroundWidget; + + // Lay out flexible content at its finite expanded height, then clip it + // to the compact/intermediate AppBar. This avoids transient RenderFlex + // overflows without giving Column/Flexible children an unbounded height. + // + // The background shifts out of view while collapsed (focus in the body) + // and back in while expanded (focus in the header), mirroring the mobile + // collapse. `fade` keeps the legacy opacity transition; the default `drop` + // translates it the full height so title and background cross-swap. + // Keep the legacy tree unchanged outside the TV focus-collapse path. + if (!_tvFocusCollapseEnabled) { + return ClipRect( + child: OverflowBox( + alignment: Alignment.topCenter, + minHeight: expandedHeight, + maxHeight: expandedHeight, + child: SizedBox( + height: expandedHeight, + child: AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: duration, + curve: curve, + child: backgroundWidget, + ), + ), + ), + ); + } + + final sizedBackground = SizedBox( + height: expandedHeight, + child: backgroundWidget, + ); + final animatedBackground = animationType == AnimationType.fade + ? AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: duration, + curve: curve, + child: sizedBackground, + ) + : AnimatedSlide( + offset: visible ? Offset.zero : const Offset(0, -1), + duration: duration, + curve: curve, + child: sizedBackground, + ); + Widget content = ClipRect( + child: OverflowBox( + alignment: Alignment.topCenter, + minHeight: expandedHeight, + maxHeight: expandedHeight, + child: animatedBackground, + ), + ); + if (!visible) { + content = TVFocusExclusion( + child: ExcludeFocus(child: IgnorePointer(child: content)), + ); + } + return content; + } + @override void dispose() { _titleBarHeightStorageSubscription?.cancel(); _headerVisibilityStorageSubscription?.cancel(); _titleBarHeightPollTimer?.cancel(); _headerVisibilityPollTimer?.cancel(); + FocusManager.instance.removeListener(_onPrimaryFocusChanged); viewGroupNotifier.removeListener(executeOnViewGroupUpdate); Ensemble().routeObserver.unsubscribe(this); @@ -1244,7 +1500,7 @@ class AnimatedAppBar extends StatefulWidget { class _AnimatedAppBarState extends State with WidgetsBindingObserver{ bool isCollapsed = false; - + @override void initState() { super.initState(); @@ -1252,7 +1508,6 @@ class _AnimatedAppBarState extends State with WidgetsBindingObse } void _updateCollapseState() { - if (!widget.scrollController.hasClients) return; double expandedHeight = (widget.expandedBarHeight ?? 0.0).toDouble(); diff --git a/modules/ensemble/lib/framework/view/page_group.dart b/modules/ensemble/lib/framework/view/page_group.dart index d6bf05006..4ceeb85a3 100644 --- a/modules/ensemble/lib/framework/view/page_group.dart +++ b/modules/ensemble/lib/framework/view/page_group.dart @@ -513,11 +513,12 @@ class PageGroupState extends State Widget? _buildSidebarSeparator(Menu menu) { Color? borderColor = Utils.getColor(menu.runtimeStyles?['borderColor']); - int? borderWidth = Utils.optionalInt(menu.runtimeStyles?['borderWidth']); + double? borderWidth = + Utils.optionalDouble(menu.runtimeStyles?['borderWidth']); if (borderColor != null || borderWidth != null) { return VerticalDivider( - thickness: (borderWidth ?? 1).toDouble(), - width: (borderWidth ?? 1).toDouble(), + thickness: borderWidth ?? 1, + width: borderWidth ?? 1, color: borderColor); } return null; diff --git a/modules/ensemble/lib/framework/widget/image.dart b/modules/ensemble/lib/framework/widget/image.dart index 7ca5d9bb8..e0b70a287 100644 --- a/modules/ensemble/lib/framework/widget/image.dart +++ b/modules/ensemble/lib/framework/widget/image.dart @@ -17,15 +17,20 @@ class Image extends StatelessWidget { this.width, this.height, this.fit, + this.resizedWidth, + this.resizedHeight, this.errorBuilder, this.placeholderBuilder, + this.loadingWidget, this.colorFilter, - this.networkCacheManager}); + this.cacheManager}); final String source; final double? width; final double? height; final BoxFit? fit; + final int? resizedWidth; + final int? resizedHeight; final ColorFilterComposite? colorFilter; final Widget Function(String)? errorBuilder; @@ -33,8 +38,11 @@ class Image extends StatelessWidget { // applicable for network image only final Widget Function(BuildContext, String)? placeholderBuilder; - // optional cache manager for network image - final BaseCacheManager? networkCacheManager; + // optional widget while the image is loading + final Widget? loadingWidget; + + // The cache policy selected by the calling Ensemble widget. + final BaseCacheManager? cacheManager; @override Widget build(BuildContext context) { @@ -48,6 +56,8 @@ class Image extends StatelessWidget { width: width, height: height, fit: fit, + cacheWidth: resizedWidth, + cacheHeight: resizedHeight, errorBuilder: errorBuilder != null ? (context, error, stackTrace) => errorBuilder!(error.toString()) : null, @@ -58,13 +68,17 @@ class Image extends StatelessWidget { width: width, height: height, fit: fit, - + memCacheWidth: resizedWidth, + memCacheHeight: resizedHeight, // placeholder while the image is loading - placeholder: placeholderBuilder, + placeholder: placeholderBuilder != null || loadingWidget != null + ? (context, url) => + loadingWidget ?? placeholderBuilder!(context, url) + : null, errorWidget: errorBuilder != null ? (context, url, error) => errorBuilder!(error.toString()) : null, - cacheManager: networkCacheManager, + cacheManager: cacheManager, ); } } else { @@ -79,10 +93,10 @@ class Image extends StatelessWidget { ); } if (colorFilter?.color != null) { - imageWidget = ColorFiltered( - colorFilter: colorFilter!.getColorFilter()!, - child: imageWidget, - ); + imageWidget = ColorFiltered( + colorFilter: colorFilter!.getColorFilter()!, + child: imageWidget, + ); } return imageWidget; } diff --git a/modules/ensemble/lib/framework/widget/view_util.dart b/modules/ensemble/lib/framework/widget/view_util.dart index f03fc447a..d66baffdb 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'; @@ -355,14 +356,14 @@ class ViewUtil { Widget? w; Function? widgetInstance = WidgetRegistry().widgetMap[model.type]; + final String? widgetId = model.props['id']?.toString(); + final dynamic previousContext = widgetId != null + ? scopeNode.scope.dataContext.getContextById(widgetId) + : null; if (widgetInstance != null) { EnsembleController? previousController; - String? id = model.props['id']?.toString(); - if (id != null) { - dynamic controller = scopeNode.scope.dataContext.getContextById(id); - if (controller is EnsembleController) { - previousController = controller; - } + if (previousContext is EnsembleController) { + previousController = previousContext; } w = Function.apply(widgetInstance, [previousController]); } else { @@ -377,6 +378,22 @@ class ViewUtil { } } + // Preserve ID-bound TV focus styles while a focused widget is rebuilt. + // Only carry focus from a widget registered in THIS scope — an ancestor + // scope hit with the same id is a collision, not the widget being rebuilt. + if (widgetId != null && + scopeNode.scope.dataContext.contextMap.containsKey(widgetId) && + previousContext is HasController && + w is HasController) { + final previousController = previousContext.controller; + final nextController = w.controller; + if (previousController is WidgetController && + nextController is WidgetController && + previousController.hasFocus) { + nextController.hasFocus = true; + } + } + if (w != null) { ScopeManager currentScope = scopeNode.scope; modelMap[model] = ModelPayload(w, currentScope); @@ -393,6 +410,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..485ccb2dc 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'; @@ -26,6 +27,23 @@ mixin HasItemTemplate { abstract class EWidgetState extends BaseWidgetState { ScopeManager? scopeManager; + ScopeManager? _bindingOwnerScope; + Invokable? _bindingOwnerDestination; + + void _syncBindingOwner(ScopeManager? scope) { + final destination = widget is Invokable ? widget as Invokable : null; + if (identical(_bindingOwnerScope, scope) && + identical(_bindingOwnerDestination, destination)) { + return; + } + PageBindingManager.releaseBindingOwner( + _bindingOwnerScope, _bindingOwnerDestination); + _bindingOwnerScope = scope; + _bindingOwnerDestination = destination; + if (scope != null && destination != null) { + PageBindingManager.retainBindingOwner(scope, destination); + } + } void resolveStylesIfUnresolved(BuildContext context) { if (widget.controller is HasStyles) { @@ -46,6 +64,11 @@ abstract class EWidgetState @override Widget build(BuildContext context) { + _syncBindingOwner(scopeManager); + if (widget is Invokable) { + scopeManager?.restoreBindingListeners(widget as Invokable, + ownerScope: scopeManager); + } resolveStylesIfUnresolved(context); Widget rtn = buildWidget(context); @@ -114,8 +137,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, @@ -213,15 +244,31 @@ abstract class EWidgetState @override void didChangeDependencies() { super.didChangeDependencies(); - scopeManager = + final nextScope = DataScopeWidget.getScope(context) ?? PageGroupWidget.getScope(context); + _syncBindingOwner(nextScope); + scopeManager = nextScope; } @override - void dispose() { - if (widget is Invokable) { - scopeManager?.removeBindingListeners(widget as Invokable); + void didUpdateWidget(covariant W oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget, widget) && oldWidget is Invokable) { + PageBindingManager.releaseBindingOwner( + _bindingOwnerScope, oldWidget as Invokable); + if (identical(_bindingOwnerDestination, oldWidget)) { + _bindingOwnerScope = null; + _bindingOwnerDestination = null; + } } + } + + @override + void dispose() { + PageBindingManager.releaseBindingOwner( + _bindingOwnerScope, _bindingOwnerDestination); + _bindingOwnerScope = null; + _bindingOwnerDestination = null; super.dispose(); } diff --git a/modules/ensemble/lib/layout/data_grid.dart b/modules/ensemble/lib/layout/data_grid.dart index d0cf51fb2..f64c605c6 100644 --- a/modules/ensemble/lib/layout/data_grid.dart +++ b/modules/ensemble/lib/layout/data_grid.dart @@ -313,7 +313,7 @@ class DataGridState extends EWidgetState dividerThickness: widget.controller.dividerThickness, border: TableBorder.all( color: widget.controller.borderColor ?? Colors.black, - width: widget.controller.borderWidth?.toDouble() ?? 1.0, + width: widget.controller.borderWidth ?? 1.0, borderRadius: widget.controller.borderRadius?.getValue() ?? BorderRadius.zero, ), diff --git a/modules/ensemble/lib/layout/grid_view.dart b/modules/ensemble/lib/layout/grid_view.dart index 9aa4ca1a4..6625872ff 100644 --- a/modules/ensemble/lib/layout/grid_view.dart +++ b/modules/ensemble/lib/layout/grid_view.dart @@ -86,6 +86,8 @@ class GridView extends StatefulWidget _controller.direction = Utils.optionalString(value), 'shrinkWrap': (value) => controller.shrinkWrap = Utils.optionalBool(value), + 'cacheExtent': (value) => + controller.cacheExtent = Utils.optionalDouble(value), }; } @@ -112,6 +114,7 @@ class GridViewController extends BoxController { ScrollController? scrollController; String? direction; bool? shrinkWrap; + double? cacheExtent; PullToRefresh? pullToRefresh; @Deprecated("use pullToRefresh") @@ -283,10 +286,11 @@ class GridViewState extends EWidgetState with TemplatedWidgetState { ? flutter.Axis.horizontal : flutter.Axis.vertical, // TODO(flutter-upgrade): on Flutter >= 3.44 use - // scrollCacheExtent: const ScrollCacheExtent.pixels(cachedPixels) + // scrollCacheExtent: + // ScrollCacheExtent.pixels(widget._controller.cacheExtent ?? cachedPixels) // https://docs.flutter.dev/release/breaking-changes/scroll-cache-extent // ignore: deprecated_member_use - cacheExtent: cachedPixels, + cacheExtent: widget._controller.cacheExtent ?? cachedPixels, // if used inside CustomScrollView (scrollableView=true), the Grid will inject the top padding // to account for the titleBar's height https://github.com/flutter/flutter/issues/108222 // We handle the titleBar ourselves already, so making sure the padding @@ -329,15 +333,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..1d03d15e0 100644 --- a/modules/ensemble/lib/layout/list_view.dart +++ b/modules/ensemble/lib/layout/list_view.dart @@ -1,9 +1,14 @@ 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/extensions.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/framework/studio/studio_debugger.dart'; +import 'package:ensemble/framework/tv/tv_focus_navigation.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'; @@ -17,10 +22,12 @@ import 'package:ensemble/page_model.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/box_wrapper.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:ensemble/widget/helpers/pull_to_refresh_container.dart'; import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' as flutter; +import 'package:flutter/rendering.dart' show RenderObject, RenderSliver; import 'helpers/list_view_core.dart'; @@ -71,6 +78,8 @@ class ListView extends StatefulWidget EnsembleAction.from(funcDefinition, initiator: this), 'reverse': (value) => _controller.reverse = Utils.getBool(value, fallback: false), + 'direction': (value) => + _controller.scrollDirection = Axis.values.from(value), 'controller': (value) { if (value is! ScrollController) return null; return _controller.scrollController = value; @@ -85,8 +94,12 @@ class ListView extends StatefulWidget controller.onScroll = EnsembleAction.from(value, initiator: this), 'shrinkWrap': (value) => controller.shrinkWrap = Utils.optionalBool(value), + 'minHeight': (value) => + controller.minHeight = Utils.optionalInt(value), 'nestedScroll': (value) => controller.nestedScroll = Utils.optionalBool(value), + 'cacheExtent': (value) => + controller.cacheExtent = Utils.optionalDouble(value), 'initialScrollOffset': (value) => _controller.initialScrollOffset = Utils.optionalDouble(value), 'initialScrollIndex': (value) => @@ -124,6 +137,7 @@ class ListViewController extends BoxLayoutController { EnsembleAction? onScrollEnd; String? onItemTapHaptic; bool reverse = false; + Axis? scrollDirection; ScrollController? scrollController; dynamic loadingWidget; bool showLoading = false; @@ -134,6 +148,8 @@ class ListViewController extends BoxLayoutController { bool? shrinkWrap; bool? nestedScroll; + double? cacheExtent; + int? minHeight; // scroll position control double? initialScrollOffset; @@ -206,11 +222,74 @@ class ListViewState extends EWidgetState List? templatedDataList; bool showLoading = false; ScrollController? _ownedScrollController; + final flutter.GlobalKey _contentFocusProbeKey = + flutter.GlobalKey(debugLabel: 'ListViewContentFocusProbe'); + // null means content changed and needs one scoped post-frame probe. + bool? _listContentHasFocusableTVTarget; + bool _contentFocusProbeScheduled = false; // FooterScope temporarily replaces scrollController; restore when that scope is gone. ScrollController? _scrollControllerOutsideFooter; bool _footerScrollControllerSubstituted = false; + // Stable key for the TV scrollbar. Created once (not per build) so the + // scrollbar's State — focus, thumb position, init — survives ListView + // rebuilds; a fresh key each build would force it to be recreated. + final flutter.GlobalKey _scrollbarKey = + flutter.GlobalKey(); + final flutter.GlobalKey _fitMeasureKey = + flutter.GlobalKey(debugLabel: 'ListViewFitContentMeasure'); + double? _fittedHeight; + bool _fitMeasureScheduled = false; + + bool get _usesFitContent => + widget._controller.minHeight != null && + widget._controller.maxHeight != null && + widget._controller.shrinkWrap == true; + + double? _measureContentExtent() { + final root = _fitMeasureKey.currentContext?.findRenderObject(); + if (root == null) return null; + + double? extent; + void visit(RenderObject node) { + if (node is RenderSliver) { + final geometry = node.geometry; + if (geometry != null && geometry.scrollExtent.isFinite) { + extent = extent == null + ? geometry.scrollExtent + : extent!.clamp(geometry.scrollExtent, double.infinity); + } + // Nested viewports are descendants of the outer sliver. + return; + } + node.visitChildren(visit); + } + + visit(root); + return extent; + } + + void _scheduleFitMeasure() { + if (!_usesFitContent || _fitMeasureScheduled) return; + _fitMeasureScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _fitMeasureScheduled = false; + if (!mounted || !_usesFitContent) return; + + final extent = _measureContentExtent(); + if (extent == null) return; + + final maxHeight = widget._controller.maxHeight!.toDouble(); + final minHeight = + widget._controller.minHeight!.toDouble().clamp(0.0, maxHeight); + final target = extent.clamp(minHeight, maxHeight); + if (_fittedHeight == null || (_fittedHeight! - target).abs() > 1.0) { + setState(() => _fittedHeight = target); + } + }); + } + @override void initState() { showLoading = widget._controller.showLoading; @@ -271,6 +350,7 @@ class ListViewState extends EWidgetState setState(() { templatedDataList = dataList; + _invalidateContentFocusProbe(); }); }); } @@ -290,9 +370,66 @@ class ListViewState extends EWidgetState void didUpdateWidget(covariant ListView oldWidget) { showLoading = widget._controller.showLoading; _attachOwnedScrollControllerIfNeeded(); + if (!identical( + oldWidget._controller.children, widget._controller.children) || + !identical(oldWidget._controller.itemTemplate, + widget._controller.itemTemplate)) { + _invalidateContentFocusProbe(); + } super.didUpdateWidget(oldWidget); } + void _invalidateContentFocusProbe() { + _listContentHasFocusableTVTarget = null; + } + + void _scheduleContentFocusProbe() { + if (_listContentHasFocusableTVTarget != null || + _contentFocusProbeScheduled) { + return; + } + + _contentFocusProbeScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _contentFocusProbeScheduled = false; + if (!mounted) return; + + final probeContext = _contentFocusProbeKey.currentContext; + // Keep the result unresolved so a later build can retry if the content + // boundary was removed before this frame completed. + if (probeContext == null) return; + + final hasFocusableTarget = _hasFocusableTVContentTarget(probeContext); + + if (_listContentHasFocusableTVTarget != hasFocusableTarget) { + setState(() { + _listContentHasFocusableTVTarget = hasFocusableTarget; + }); + } + }); + } + + bool _hasFocusableTVContentTarget(BuildContext probeContext) { + final contentScope = flutter.FocusScope.of( + probeContext, + createDependency: false, + ); + for (final focusNode in contentScope.descendants) { + final nodeContext = focusNode.context; + if (nodeContext == null) continue; + if (!focusNode.canRequestFocus) continue; + + final focusOrder = nodeContext + .findAncestorWidgetOfExactType() + ?.order; + if (focusOrder is flutter.FocusOrder) { + return true; + } + } + + return false; + } + @override Widget buildWidget(BuildContext context) { widget.controller._bind(this); @@ -310,6 +447,8 @@ class ListViewState extends EWidgetState shrinkWrap: widget._controller.shrinkWrap ?? (FooterScope.of(context) != null ? true : false), nestedScroll: widget._controller.nestedScroll?? false, + cacheExtent: widget._controller.cacheExtent, + scrollDirection: widget._controller.scrollDirection ?? Axis.vertical, itemCount: itemCount, isLoading: showLoading, onFetchData: _fetchData, @@ -387,6 +526,10 @@ class ListViewState extends EWidgetState }, ); + if (_usesFitContent) { + listView = flutter.KeyedSubtree(key: _fitMeasureKey, child: listView); + } + // if we don't shrinkWrap, the ListView used inside Column or scrollable height // would cause an error, so we check for that in Studio mode. // Note that we don't need to check for explicit height since that will @@ -401,13 +544,180 @@ class ListViewState extends EWidgetState options: pullToRefresh!, contentWidget: listView); } - return BoxWrapper( + // TV: Add focusable scrollbar if configured + Widget? outsideScrollbar; + if (Device().isTV && widget._controller.tvOptions?.scrollbarOptions != null) { + final tvOptions = widget._controller.tvOptions!; + final scrollbarOptions = tvOptions.scrollbarOptions!; + final scrollController = widget._controller.scrollController; + + if (scrollController != null) { + if (tvOptions.row != null) { + _scheduleContentFocusProbe(); + } + + final fallbackFocus = + _listContentHasFocusableTVTarget == false && tvOptions.row != null + ? TVScrollbarFallbackFocusConfig( + row: tvOptions.row!, + order: tvOptions.order ?? 0, + focusGroup: resolveTVFocusGroup(context, tvOptions), + isRowEntryPoint: tvOptions.isRowEntryPoint, + ) + : null; + + // Uses the stable _scrollbarKey (a State field) so the scrollbar keeps + // its focus/thumb state across ListView rebuilds. + final scrollbarWidget = TVScrollbarWidget( + key: _scrollbarKey, + scrollController: scrollController, + options: scrollbarOptions, + fallbackFocus: fallbackFocus, + // Reveal the scrollbar with the same scroll tuning as the content + // items beside it (box_wrapper reads these same tvOptions). + verticalScrollPadding: tvOptions.verticalScrollPadding, + scrollAnimationDuration: tvOptions.scrollAnimationDuration, + scrollAnimationCurve: tvOptions.scrollAnimationCurve, + ); + final effectiveScrollbarWidget = fallbackFocus == null + ? flutter.FocusTraversalGroup( + policy: flutter.WidgetOrderTraversalPolicy(), + child: scrollbarWidget, + ) + : scrollbarWidget; + + // Callback to request focus on scrollbar + void requestScrollbarFocus() { + _scrollbarKey.currentState?.requestFocusOnScrollbar(); + } + + // Determine scrollbar position and which edge handler to use + final isLeftPosition = scrollbarOptions.position == 'left'; + final isOutside = + scrollbarOptions.placement == TVScrollbarPlacement.outside; + final probedContent = tvOptions.row != null + ? flutter.KeyedSubtree( + key: _contentFocusProbeKey, + child: listView, + ) + : listView; + + // 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: probedContent, + ); + + // Keep the scrollbar beside the content. The fit-content wrapper above + // supplies the finite height required by the stretched row. + listView = flutter.NotificationListener< + flutter.ScrollMetricsNotification>( + onNotification: (notification) { + // Content can grow after this cached ListView first initializes. + // ScrollController listeners do not fire for that metrics-only + // change, so refresh the sibling TV scrollbar explicitly. Only + // for this list's own Scrollable (depth 0) -- a nested + // scrollable inside an item template bubbles this notification + // too, and its metrics don't affect this list's scrollbar. + if (notification.depth == 0) { + _scrollbarKey.currentState?.updateForScrollMetrics(); + _scheduleFitMeasure(); + } + return false; + }, + child: flutter.Row( + crossAxisAlignment: flutter.CrossAxisAlignment.stretch, + children: isLeftPosition + ? [ + if (!isOutside) effectiveScrollbarWidget, + flutter.Expanded(child: scopedContent), + ] + : [ + flutter.Expanded(child: scopedContent), + if (!isOutside) effectiveScrollbarWidget, + ], + ), + ); + if (isOutside) { + outsideScrollbar = effectiveScrollbarWidget; + } + } + } + + if (_usesFitContent && + !(Device().isTV && widget._controller.tvOptions?.scrollbarOptions != null)) { + listView = flutter.NotificationListener( + onNotification: (notification) { + if (notification.depth == 0) _scheduleFitMeasure(); + return false; + }, + child: listView, + ); + } + + if (widget._controller.shrinkWrap == true) { + final maxHeight = widget._controller.maxHeight == null + ? null + : widget._controller.maxHeight!.clamp(0, double.infinity).toDouble(); + final minHeight = widget._controller.minHeight == null + ? null + : widget._controller.minHeight! + .clamp(0, maxHeight ?? double.infinity) + .toDouble(); + if (_usesFitContent) { + _scheduleFitMeasure(); + listView = flutter.SizedBox( + height: _fittedHeight ?? maxHeight!, + child: listView, + ); + } else if (minHeight != null || maxHeight != null) { + listView = flutter.ConstrainedBox( + constraints: flutter.BoxConstraints( + minHeight: minHeight ?? 0.0, + maxHeight: maxHeight ?? double.infinity, + ), + child: listView, + ); + } + } + + final boxedListView = BoxWrapper( boxController: widget._controller, + ignoresTVFocus: widget._controller.tvOptions?.scrollbarOptions != null, widget: DefaultTextStyle.merge( style: TextStyle( fontFamily: widget._controller.fontFamily, fontSize: widget._controller.fontSize?.toDouble()), child: listView)); + if (outsideScrollbar == null) { + return boxedListView; + } + + // Scrollbar beside the box. Its slot keeps the focused width so the box + // does not resize when the scrollbar gains focus. + final scrollbarOptions = widget._controller.tvOptions!.scrollbarOptions!; + final isLeftPosition = scrollbarOptions.position == 'left'; + final scrollbarSlot = flutter.SizedBox( + width: scrollbarOptions.focusedWidth > scrollbarOptions.width + ? scrollbarOptions.focusedWidth + : scrollbarOptions.width, + child: flutter.Align( + alignment: isLeftPosition + ? flutter.Alignment.centerRight + : flutter.Alignment.centerLeft, + child: outsideScrollbar, + ), + ); + final gap = flutter.SizedBox(width: scrollbarOptions.gap); + return flutter.Row( + crossAxisAlignment: flutter.CrossAxisAlignment.stretch, + children: isLeftPosition + ? [scrollbarSlot, gap, flutter.Flexible(child: boxedListView)] + : [flutter.Flexible(child: boxedListView), gap, scrollbarSlot], + ); } // backward compatible @@ -462,4 +772,4 @@ class ListViewState extends EWidgetState ScreenController().executeAction(context, widget._controller.onScrollEnd!); } -} \ No newline at end of file +} diff --git a/modules/ensemble/lib/layout/tab/base_tab_bar.dart b/modules/ensemble/lib/layout/tab/base_tab_bar.dart index 3e2c085e0..60f925f63 100644 --- a/modules/ensemble/lib/layout/tab/base_tab_bar.dart +++ b/modules/ensemble/lib/layout/tab/base_tab_bar.dart @@ -1,13 +1,26 @@ +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_provider.dart'; +import 'package:ensemble/framework/tv/tv_focus_scroll.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'; import 'package:ensemble/layout/tab_bar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:ensemble/framework/widget/icon.dart' as ensemble; +// ============================================================================= +// 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; @@ -15,6 +28,99 @@ abstract class BaseTabBarState extends EWidgetState // execute when a tab has (already) changed. Use this to run extra logic (and don't modify the tabController again void onTabChanged(int index); + // --------------------------------------------------------------------------- + // TV: return focus to the selected tab on Back + // --------------------------------------------------------------------------- + + /// The mounted TV tab buttons, keyed by visible tab index. + /// + /// Populated by [_TVTabButtonState] so [focusSelectedTab] can target the + /// selected tab directly, rather than through grid coordinates (whose + /// nearest-match fallback could land on a different control). + final Map _tvTabFocusEntries = {}; + + void _registerTVTabFocusEntry(int index, _TVTabFocusEntry entry) { + _tvTabFocusEntries[index] = entry; + } + + void _unregisterTVTabFocusEntry(int index, _TVTabFocusEntry entry) { + // Only drop the entry if it is still this one: a replacement button for the + // same index must not be removed by the outgoing one. + if (identical(_tvTabFocusEntries[index], entry)) { + _tvTabFocusEntries.remove(index); + } + } + + /// Focuses the currently selected tab button, returning whether focus moved. + /// + /// Uses the index the strip itself treats as selected, and reveals the strip + /// explicitly: the first focus gain on a tab is intentionally not revealed + /// (legacy initial-autofocus guard), so a Back press from scrolled-away + /// content would otherwise move focus somewhere the user cannot see. + /// + /// Returns false when this TabBar has no TV tab buttons (mobile/web builds), + /// which leaves the Back press to the platform. + bool focusSelectedTab() { + final entry = _tvTabFocusEntries[tabController.index]; + if (entry == null || entry.node.hasFocus || !entry.node.canRequestFocus) { + return false; + } + entry.node.requestFocus(); + entry.reveal(); + return true; + } + + // A Back key-down handled by the content scope must also consume the paired + // key-up. Android tracks the down/up pair and can redispatch an unhandled up + // to Activity.onKeyUp, which may pop the route even though focus has moved. + bool _handledTabBackKeySequence = false; + + KeyEventResult handleTabBackKeySequence(KeyEvent event) { + if (event.logicalKey != LogicalKeyboardKey.goBack || + !_handledTabBackKeySequence) { + return KeyEventResult.ignored; + } + + // A non-repeat down is a new physical press (normally preceded by the + // release); don't let a missing/cancelled up leave navigation disabled. + if (event is KeyDownEvent) { + _handledTabBackKeySequence = false; + return KeyEventResult.ignored; + } + if (event is KeyUpEvent) { + _handledTabBackKeySequence = false; + } + // Consume repeats and the paired release for the handled press. + return KeyEventResult.handled; + } + + /// Key handler for the opt-in TabBar content wrapper + /// (`styles.tabFocusOnBack: true`). + /// + /// Consumes a plain Back key-down only when focus successfully moves. That + /// handled press's repeats and key-up are then consumed by the surrounding + /// TabBar scope, so they cannot re-trigger or navigate by themselves. + /// The wrapper surrounds the tab *content* only, so once focus is on this + /// TabBar's own strip the handler is no longer in the focus path and the next + /// Back press reaches normal navigation untouched. If this TabBar is nested + /// inside another opted-in TabBar, Back walks out one tab level per press. + KeyEventResult handleTabBodyBackKey(KeyEvent event) { + if (_handledTabBackKeySequence) { + final result = handleTabBackKeySequence(event); + // A fresh key-down means the previous press's key-up never arrived; the + // flag is now cleared, so handle this press normally. + if (event is! KeyDownEvent) return result; + } + if (event is! KeyDownEvent || + event.logicalKey != LogicalKeyboardKey.goBack) { + return KeyEventResult.ignored; + } + if (!focusSelectedTab()) return KeyEventResult.ignored; + + _handledTabBackKeySequence = true; + return KeyEventResult.handled; + } + // common method for both scrollable and simple Tabbar bool evaluateCondition(ScopeManager scopeManager, String expression) { try { @@ -27,6 +133,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 + // Each tab is an individually focusable button + if (Device().isTV) { + return _buildTVTabBar(); + } + TextStyle? tabStyle = TextStyle( fontSize: widget.controller.tabFontSize?.toDouble(), fontWeight: widget.controller.tabFontWeight); @@ -52,10 +164,25 @@ abstract class BaseTabBarState extends EWidgetState final tabAlignment = TabAlignment.values.from(widget.controller.tabAlignment) ?? TabAlignment.start; + final overlayIndicator = widget.controller.overlayIndicator ?? false; + + final dividerColor = widget.controller.dividerColor ?? Colors.transparent; + final dividerThickness = + widget.controller.dividerThickness?.toDouble() ?? 1; + final dividerPadding = widget.controller.dividerPadding; + + // dividerHeight reserves no height -- Material paints the divider inside + // the tab row (tabs.dart:2049) -- so lay our own out below the tabs. + final wantsCustomDivider = widget.controller.dividerColor != null && + (dividerPadding != null || + widget.controller.dividerThickness != null || + widget.controller.overlayIndicator != null); Widget tabBar = TabBar( labelPadding: labelPadding, - dividerColor: widget.controller.dividerColor ?? Colors.transparent, + dividerColor: wantsCustomDivider ? Colors.transparent : dividerColor, + // null keeps TabBarThemeData.dividerHeight resolving for older apps. + dividerHeight: wantsCustomDivider ? 0 : null, indicator: indicatorThickness == 0 ? BoxDecoration( color: widget.controller.activeTabBackgroundColor ?? @@ -79,6 +206,45 @@ abstract class BaseTabBarState extends EWidgetState tabs: _buildTabs(widget.controller.items), onTap: onTabChanged); + if (wantsCustomDivider) { + final divider = Padding( + padding: EdgeInsets.only( + left: dividerPadding?.left ?? 0, + right: dividerPadding?.right ?? 0, + ), + child: SizedBox( + height: dividerThickness, + child: ColoredBox(color: dividerColor), + ), + ); + + if (overlayIndicator) { + tabBar = Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + height: dividerThickness, + child: divider, + ), + tabBar, + ], + ); + } else { + tabBar = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + tabBar, + SizedBox(height: dividerPadding?.top ?? 0), + divider, + SizedBox(height: dividerPadding?.bottom ?? 0), + ], + ); + } + } + if (widget.controller.tabBackgroundColor != null) { tabBar = ColoredBox( color: widget.controller.tabBackgroundColor!, child: tabBar); @@ -89,15 +255,203 @@ abstract class BaseTabBarState extends EWidgetState tabBar = Container( decoration: BoxDecoration( border: Border.all( - color: widget.controller.borderColor ?? Colors.transparent, // Customize as needed + color: widget.controller.borderColor ?? + Colors.transparent, // Customize as needed width: (widget.controller.borderWidth ?? 0.0).toDouble(), ), borderRadius: borderRadius ?? BorderRadius.zero, ), child: ClipRRect( - borderRadius: borderRadius ?? BorderRadius.zero, - child: tabBar + borderRadius: borderRadius ?? BorderRadius.zero, child: tabBar), + ); + } + + return tabBar; + } + + /// Build TV-specific tab bar with individually focusable buttons. + /// Uses 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 focusIndicatorColor = widget.controller.focusIndicatorColor; + final focusTabColor = widget.controller.focusTabColor; + final backgroundColor = widget.controller.tabBackgroundColor; + final dividerColor = widget.controller.dividerColor ?? Colors.transparent; + final dividerThickness = + widget.controller.dividerThickness?.toDouble() ?? 1; + final dividerPadding = widget.controller.dividerPadding; + final overlayIndicator = widget.controller.overlayIndicator ?? false; + final indicatorThickness = + widget.controller.indicatorThickness?.toDouble() ?? 2; + + // Get tvOptions for row + final tvOptions = widget.controller.tvOptions; + // Prefer the TabBar's own `tvRow` property; fall back to tvOptions.row, then 0. + final tvRow = widget.controller.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) { + final tvTabAlignment = + TabAlignment.values.from(widget.controller.tabAlignment) ?? + TabAlignment.start; + + List buildButtons() => 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: widget.controller.autofocus && index == 0, + activeColor: activeColor, + inactiveColor: inactiveColor, + indicatorColor: indicatorColor, + focusIndicatorColor: focusIndicatorColor, + focusTabColor: focusTabColor, + indicatorThickness: indicatorThickness, + tabFontSize: widget.controller.tabFontSize?.toDouble(), + tabFontWeight: widget.controller.tabFontWeight, + tabPadding: widget.controller.tabPadding, + scrollPadding: widget.controller.tvOptions?.verticalScrollPadding, + revealTabBarOnFocus: widget.controller.revealTabBarOnFocus, + registerFocusEntry: _registerTVTabFocusEntry, + unregisterFocusEntry: _unregisterTVTabFocusEntry, + onTap: () { + tabController.animateTo(index); + onTabChanged(index); + }, + ); + }); + + // `fill` needs bounded constraints and Expanded children, which can't + // live in a scroll view; every other alignment keeps scrolling. + Widget tabRow; + if (tvTabAlignment == TabAlignment.fill) { + tabRow = Row( + children: [ + for (final button in buildButtons()) Expanded(child: button), + ], + ); + } else { + tabRow = LayoutBuilder(builder: (context, constraints) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + // Viewport-width minimum so non-start alignment has room; + // still scrolls on overflow. + constraints: constraints.hasBoundedWidth + ? BoxConstraints(minWidth: constraints.maxWidth) + : const BoxConstraints(), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: tvTabAlignment == TabAlignment.center + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + // 52px: the same offset Flutter uses on mobile. + if (tvTabAlignment == TabAlignment.startOffset) + const SizedBox(width: 52.0), + ...buildButtons(), + ], + ), + ), + ); + }); + } + + // Apply tabBarPadding to the tabs row only (not content area) + if (widget.controller.tabBarPadding != null) { + tabRow = Padding( + padding: widget.controller.tabBarPadding!, + child: tabRow, + ); + } + + // Only draw when a divider property is set. dividerColor alone + // predates this feature and TV never drew a line for it. + if (widget.controller.dividerColor == null || + (dividerPadding == null && + widget.controller.dividerThickness == null && + widget.controller.overlayIndicator == null)) { + return tabRow; + } + + final divider = Padding( + padding: EdgeInsets.only( + left: dividerPadding?.left ?? 0, + right: dividerPadding?.right ?? 0, + ), + child: SizedBox( + height: dividerThickness, + child: ColoredBox(color: dividerColor), + ), + ); + + if (overlayIndicator) { + return Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + height: dividerThickness, + child: divider, + ), + tabRow, + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + tabRow, + SizedBox(height: dividerPadding?.top ?? 0), + divider, + SizedBox(height: dividerPadding?.bottom ?? 0), + ], + ); + }, + ); + + // Isolate the tabs only when no focus row was given -- either property + // opts in to the shared page grid. + if (widget.controller.tvRow == null && 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), ); } @@ -115,13 +469,15 @@ abstract class BaseTabBarState extends EWidgetState Widget _buildTabWidget(ScopeManager? scopeManager, TabItem tabItem) { final tabWidget = tabItem.tabWidget; - final label = scopeManager!.dataContext.eval(tabItem.label); if (scopeManager != null && tabWidget != null) { final customWidget = scopeManager.buildWidgetFromDefinition(tabWidget); return Tab( child: customWidget, ); } + // Fall back to a text/icon tab. eval needs a scope; if there's none, the + // label degrades to null rather than crashing on a force-unwrap. + final label = scopeManager?.dataContext.eval(tabItem.label); return Tab( text: label, icon: @@ -133,3 +489,309 @@ abstract class BaseTabBarState extends EWidgetState mixin TabBarAction on EWidgetState { void changeTab(int index); } + +/// A mounted TV tab button, as seen by its owning TabBar: the focus node that +/// Back should target, plus the reveal that makes it visible (the button only +/// auto-reveals on a repeat focus gain, not on the first one). +class _TVTabFocusEntry { + const _TVTabFocusEntry(this.node, this.reveal); + + final FocusNode node; + final VoidCallback reveal; +} + +/// TV-specific focusable tab button using row/order-based navigation. +/// Each tab uses TVFocusOrder coordinates. +/// If TabBar has tvRow set, tabs use that row in the main page grid. +/// 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, + this.focusIndicatorColor, + this.focusTabColor, + required this.indicatorThickness, + required this.onTap, + this.autofocus = false, + this.tabFontSize, + this.tabFontWeight, + this.tabPadding, + this.scrollPadding, + this.revealTabBarOnFocus = false, + required this.registerFocusEntry, + required this.unregisterFocusEntry, + }); + + 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 Color? focusIndicatorColor; + final Color? focusTabColor; + final double indicatorThickness; + final VoidCallback onTap; + final double? tabFontSize; + final FontWeight? tabFontWeight; + final EdgeInsets? tabPadding; + + /// From tvOptions.verticalScrollPadding: how far from the viewport edge the + /// revealed TabBar (strip + active body) should sit. + final double? scrollPadding; + final bool revealTabBarOnFocus; + + /// Registers/unregisters this button with the owning TabBar, so Back inside + /// the tab content can return focus to the selected tab. + final void Function(int index, _TVTabFocusEntry entry) registerFocusEntry; + final void Function(int index, _TVTabFocusEntry entry) unregisterFocusEntry; + + @override + State<_TVTabButton> createState() => _TVTabButtonState(); +} + +class _TVTabButtonState extends State<_TVTabButton> { + late final FocusNode _focusNode; + late final _TVTabFocusEntry _focusEntry; + bool _hasReceivedFocus = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'TVTabButton_${widget.index}'); + _focusNode.addListener(_onFocusChange); + _focusEntry = _TVTabFocusEntry(_focusNode, _scrollIntoView); + widget.registerFocusEntry(widget.index, _focusEntry); + } + + @override + void dispose() { + widget.unregisterFocusEntry(widget.index, _focusEntry); + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onFocusChange() { + if (_focusNode.hasFocus) { + // Tabs are custom focusables rather than BoxWrapper widgets. Remember + // their enclosing page scroller so resetScrollOnFocus also works when + // focus returns from the tab strip itself. + final scrollable = findOutermostVerticalScrollable(context); + if (scrollable != null) { + rememberActiveVerticalScrollable( + ModalRoute.of(context), scrollable); + } + } + + // Preserve the legacy initial-focus guard unless whole-Bar reveal is + // explicitly enabled for this TabBar. + if (_focusNode.hasFocus && + (_hasReceivedFocus || widget.revealTabBarOnFocus) && + mounted) { + _scrollIntoView(); + } + // Mark that we've received focus at least once + if (_focusNode.hasFocus) { + _hasReceivedFocus = true; + } + } + + void _scrollIntoView() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (!widget.revealTabBarOnFocus) { + // Keep the legacy target and scrolling behavior unchanged. + Scrollable.ensureVisible( + context, + alignment: 0.0, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + return; + } + + // Opt-in behavior: reveal the enclosing TabBar instead of only the + // focused tab. Expanded TabBars still use the focused tab as target. + final tabBarState = context.findAncestorStateOfType(); + final isStretched = tabBarState?.widget.controller.isStretched ?? false; + final targetContext = tabBarState != null && + !isStretched + ? tabBarState.context + : context; + scrollWidgetIntoView( + targetContext, + verticalPadding: widget.scrollPadding ?? kTVVerticalScrollPadding, + ); + _scrollHorizontalIntoView(); + }); + } + + ScrollableState? _findHorizontalScrollable() { + ScrollableState? scrollable; + context.visitAncestorElements((element) { + if (element.widget is Scrollable) { + final state = (element as StatefulElement).state; + if (state is ScrollableState) { + final axis = state.axisDirection; + if (axis == AxisDirection.left || axis == AxisDirection.right) { + scrollable = state; + return false; + } + } + } + return true; + }); + return scrollable; + } + + void _scrollHorizontalIntoView() { + final horizontal = _findHorizontalScrollable(); + if (horizontal == null || !horizontal.position.hasContentDimensions) return; + final box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + horizontal.position.ensureVisible( + box, + 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 + ? (widget.focusIndicatorColor ?? Colors.blue) + : widget.isSelected + ? widget.indicatorColor + : Colors.transparent, + borderRadius: + BorderRadius.circular(widget.indicatorThickness / 2), + ), + ), + ], + ), + ); + }, + ), + ); + + // Wrap with the active TV focus system 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. + final externalProvider = TVFocusProviderScope.maybeOf(context); + if (externalProvider != null) { + return externalProvider.wrapFocusable( + row: widget.tabRow + externalProvider.rowOffset, + order: widget.index.toDouble() + externalProvider.orderOffset, + isRowEntryPoint: widget.isSelected, + primaryFocusNode: _focusNode, + child: inkWell, + ); + } + + return TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + widget.tabRow, + order: widget.index.toDouble(), + isRowEntryPoint: widget.isSelected, // selected tab is the entry point + ), + // Register the tab's own InkWell FocusNode so navigation lands on the + // visible tab control rather than an empty wrapper. + primaryFocusNode: _focusNode, + child: inkWell, + ); + } + + Widget _buildTabContent(BuildContext context, bool isFocused) { + final textColor = isFocused + // Not white: that's invisible on a light tab bar. + ? (widget.focusTabColor ?? widget.activeColor) + : 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..7d11f50ad 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) => @@ -68,8 +70,18 @@ class ScrollableTabBar extends BaseTabBar { _controller.dividerColor = Utils.getColor(color), 'indicatorColor': (color) => _controller.indicatorColor = Utils.getColor(color), + 'focusIndicatorColor': (color) => + _controller.focusIndicatorColor = Utils.getColor(color), + 'focusTabColor': (color) => + _controller.focusTabColor = Utils.getColor(color), 'indicatorThickness': (thickness) => _controller.indicatorThickness = Utils.optionalInt(thickness), + 'dividerThickness': (thickness) => + _controller.dividerThickness = Utils.optionalInt(thickness, min: 0), + 'dividerPadding': (padding) => + _controller.dividerPadding = Utils.optionalInsets(padding), + 'overlayIndicator': (overlay) => + _controller.overlayIndicator = Utils.optionalBool(overlay), 'selectedIndex': (index) => _controller.selectedIndex = Utils.getInt(index, min: 0, fallback: 0), 'onTabSelection': (action) => _controller.onTabSelection = diff --git a/modules/ensemble/lib/layout/tab/tab_bar_controller.dart b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart index 38e448c1b..6f2d71aed 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; @@ -22,8 +27,44 @@ class TabBarController extends BoxController { Color? activeTabBackgroundColor; Color? indicatorColor; Color? dividerColor; + int? dividerThickness; + EdgeInsets? dividerPadding; + bool? overlayIndicator; + Color? focusIndicatorColor; // TV D-pad: focus-state indicator color (fallback blue) + Color? focusTabColor; // TV D-pad: focus-state tab text color (fallback white) 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; + + /// TV: reveal the whole TabBar when a tab receives focus. + bool revealTabBarOnFocus = false; + + /// TV: when true, pressing Back while focus is inside this TabBar's content + /// moves focus back to the selected tab instead of leaving the screen. The + /// next Back press (now on the tab strip) navigates as usual. Opt-in and + /// default false, so existing behavior is unchanged. + /// + /// Applies to `TabBar` (TabBarContainer) content. `TabBarOnly` has no content + /// to return from, and `ScrollableTabBar` selects by scroll position rather + /// than by a per-tab body, so both ignore this flag. + /// Set with `styles.tabFocusOnBack`. + bool tabFocusOnBack = false; + + /// Whether the TabBar body occupies the available flex height. + bool get isStretched { + if (flexMode == FlexMode.expanded || + flexMode == FlexMode.flexible || + (flex != null && flexMode != FlexMode.none)) { + return true; + } + // Preserve the legacy layout flag without adding deprecated accesses at + // each call site. + return (this as dynamic).expanded == true; + } + EnsembleAction? onTabSelection; String? onTabSelectionHaptic; TabBarAction? tabBarAction; @@ -67,6 +108,11 @@ class TabBarController extends BoxController { var setters = super.getBaseSetters(); setters.addAll({ 'items': (values) => items = values, + 'tvRow': (value) => tvRow = Utils.optionalDouble(value), + 'revealTabBarOnFocus': (value) => + revealTabBarOnFocus = Utils.getBool(value, fallback: false), + 'tabFocusOnBack': (value) => + tabFocusOnBack = Utils.getBool(value, fallback: false), }); return setters; } diff --git a/modules/ensemble/lib/layout/tab_bar.dart b/modules/ensemble/lib/layout/tab_bar.dart index 648ab60c4..6e1a6acbe 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) => @@ -82,8 +90,18 @@ abstract class BaseTabBar extends StatefulWidget _controller.dividerColor = Utils.getColor(color), 'indicatorColor': (color) => _controller.indicatorColor = Utils.getColor(color), + 'focusIndicatorColor': (color) => + _controller.focusIndicatorColor = Utils.getColor(color), + 'focusTabColor': (color) => + _controller.focusTabColor = Utils.getColor(color), 'indicatorThickness': (thickness) => _controller.indicatorThickness = Utils.optionalInt(thickness), + 'dividerThickness': (thickness) => + _controller.dividerThickness = Utils.optionalInt(thickness, min: 0), + 'dividerPadding': (padding) => + _controller.dividerPadding = Utils.optionalInsets(padding), + 'overlayIndicator': (overlay) => + _controller.overlayIndicator = Utils.optionalBool(overlay), 'selectedIndex': (index) => _controller.selectedIndex = Utils.getInt(index, min: 0, fallback: 0), 'onTabSelection': (action) => _controller.onTabSelection = @@ -98,8 +116,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 @@ -312,6 +332,19 @@ class TabBarState extends BaseTabBarState { return buildSelectedTab(); }, ); + // Opt-in TV Back handling (`styles.tabFocusOnBack: true`): while + // focus is inside this TabBar's content, Back moves focus to the selected + // tab instead of leaving the screen. The wrapper surrounds the content + // only, so the tab strip and everything outside this TabBar keep their + // existing Back behavior. Non-TV builds have no TV tab buttons + // registered, so handleTabBodyBackKey leaves the press to the platform. + if (widget._controller.tabFocusOnBack) { + tabContent = Focus( + canRequestFocus: false, + onKeyEvent: (_, KeyEvent event) => handleTabBodyBackKey(event), + child: tabContent, + ); + } if (isExpanded) { tabContent = Expanded(child: tabContent); } @@ -320,18 +353,20 @@ 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 (widget._controller.tabFocusOnBack) { + // Keep this Focus below the outer Expanded. Expanded must remain the + // direct Flex child when the TabBar sits in a Column with `expanded: true`. + tabWidget = Focus( + canRequestFocus: false, + onKeyEvent: (_, event) => handleTabBackKeySequence(event), + child: tabWidget, + ); + } // if Expanded is set, stretch our column to left-over height if (isExpanded) { tabWidget = Expanded(child: tabWidget); diff --git a/modules/ensemble/lib/page_model.dart b/modules/ensemble/lib/page_model.dart index 4c5608ad1..c8bf329c4 100644 --- a/modules/ensemble/lib/page_model.dart +++ b/modules/ensemble/lib/page_model.dart @@ -316,6 +316,11 @@ class SinglePageModel extends PageModel with HasStyles { final String type = 'View'; ScreenOptions? screenOptions; WidgetModel? rootWidgetModel; + + /// Optional widget-based page background (`View.styles.background`). + /// Rendered as the bottom-most layer, behind the body, and kept + /// non-focusable/non-interactive by the page. + WidgetModel? backgroundWidget; FooterItems? footer; @override @@ -348,6 +353,15 @@ class SinglePageModel extends PageModel with HasStyles { if (viewMap['styles'] is YamlMap) { inlineStyles = {}; (viewMap['styles'] as YamlMap).forEach((key, value) { + // `background` is a widget-based page background (like the + // header's flexibleBackground), not a style token. Build it as a + // widget model here and keep it out of theme/style resolution. + if (key == 'background' && + ViewUtil.isViewModel(value, customViewDefinitions)) { + backgroundWidget = + ViewUtil.buildModel(value, customViewDefinitions); + return; + } inlineStyles![key] = EnsembleThemeManager.yamlToDart(value); }); } diff --git a/modules/ensemble/lib/screen_controller.dart b/modules/ensemble/lib/screen_controller.dart index c454353b3..ab6bf118d 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'; @@ -766,6 +767,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/util/ensemble_utils.dart b/modules/ensemble/lib/util/ensemble_utils.dart index 62d87911a..abff81575 100644 --- a/modules/ensemble/lib/util/ensemble_utils.dart +++ b/modules/ensemble/lib/util/ensemble_utils.dart @@ -1,5 +1,6 @@ import 'package:ensemble/ensemble.dart'; import 'package:ensemble/ensemble_app.dart'; +import 'package:ensemble/framework/tv/tv_tooltip_registry.dart'; import 'package:flutter/material.dart'; class EnsembleUtils { @@ -69,4 +70,14 @@ class EnsembleUtils { } return Future.value(false); } + + /// dismiss the top-most TV tooltip (if any) + /// + /// A tooltip is an [OverlayPortal], not a route, so it is closed through + /// [TVTooltipRegistry] instead of the Navigator. [context] scopes the + /// dismissal to the tooltip opened on the same route when possible. + static Future dismissTooltip([BuildContext? context]) { + final route = context != null ? ModalRoute.of(context) : null; + return Future.value(TVTooltipRegistry.dismiss(route: route)); + } } diff --git a/modules/ensemble/lib/util/utils.dart b/modules/ensemble/lib/util/utils.dart index 695b021d5..5d3136f44 100644 --- a/modules/ensemble/lib/util/utils.dart +++ b/modules/ensemble/lib/util/utils.dart @@ -5,11 +5,13 @@ import 'dart:ui'; import 'package:ensemble/ensemble.dart'; import 'package:ensemble/framework/assets_service.dart'; import 'package:ensemble/framework/cdn_asset_cache.dart'; +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/ensemble_config_service.dart'; import 'package:ensemble/framework/stub/location_manager.dart'; import 'package:ensemble/framework/theme/theme_manager.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:ensemble/widget/helpers/tooltip_composite.dart'; +import 'package:ensemble/widget/helpers/tv_tooltip.dart'; import 'package:ensemble_ts_interpreter/invokables/UserLocale.dart'; import 'package:path/path.dart' as p; @@ -572,6 +574,10 @@ class Utils { final tooltip = TooltipData.from(tooltipData, controller); if (tooltip == null) return child; + if (Device().isTV && tooltip.widget != null) { + return TVTooltip(child: child, tooltip: tooltip); + } + final tooltipKey = GlobalKey(); // Start with the original child Widget tooltipChild = child; @@ -612,9 +618,8 @@ class Utils { ? Border.all( color: tooltip.styles?.borderColor ?? ThemeManager().getBorderColor(context), - width: (tooltip.styles?.borderWidth ?? - ThemeManager().getBorderThickness(context)) - .toDouble(), + width: tooltip.styles?.borderWidth ?? + ThemeManager().getBorderThickness(context), ) : null, ), @@ -863,39 +868,39 @@ class Utils { } static BorderRadiusGeometry? getBorderRadiusGeometry(dynamic value) { - if (value is int) { + if (value is num) { // Optimize: Ignore zero border radius as it causes unnecessary clipping if (value != 0) { return BorderRadius.all(Radius.circular(value.toDouble())); } } else if (value is String) { - // Convert the string to a list of integers - List numbers = stringToIntegers(value, min: 0); + // Convert the string to a list of non-negative numbers + List numbers = stringToDoubles(value, min: 0); // Handle 1 to 4 values for BorderRadius switch (numbers.length) { case 1: - return BorderRadius.all(Radius.circular(numbers[0].toDouble())); + return BorderRadius.all(Radius.circular(numbers[0])); case 2: return BorderRadius.vertical( - top: Radius.circular(numbers[0].toDouble()), - bottom: Radius.circular(numbers[1].toDouble()), + top: Radius.circular(numbers[0]), + bottom: Radius.circular(numbers[1]), ); case 3: return BorderRadius.only( - topLeft: Radius.circular(numbers[0].toDouble()), - topRight: Radius.circular(numbers[1].toDouble()), - bottomLeft: Radius.circular(numbers[2].toDouble()), + topLeft: Radius.circular(numbers[0]), + topRight: Radius.circular(numbers[1]), + bottomLeft: Radius.circular(numbers[2]), ); case 4: return BorderRadius.only( - topLeft: Radius.circular(numbers[0].toDouble()), - topRight: Radius.circular(numbers[1].toDouble()), - bottomRight: Radius.circular(numbers[2].toDouble()), - bottomLeft: Radius.circular(numbers[3].toDouble()), + topLeft: Radius.circular(numbers[0]), + topRight: Radius.circular(numbers[1]), + bottomRight: Radius.circular(numbers[2]), + bottomLeft: Radius.circular(numbers[3]), ); default: - throw LanguageError('borderRadius requires 1 to 4 integers'); + throw LanguageError('borderRadius requires 1 to 4 numbers'); } } @@ -975,13 +980,13 @@ static BoxDecoration? getBoxDecoration(dynamic style) { } static EBorderRadius? getBorderRadius(dynamic value) { - if (value is int) { + if (value is num) { // optimize, ignore zero border radius as that causes extra processing for clipping if (value != 0) { - return EBorderRadius.all(value); + return EBorderRadius.all(value.toDouble()); } } else if (value is String) { - List numbers = stringToIntegers(value, min: 0); + List numbers = stringToDoubles(value, min: 0); if (numbers.length == 1) { return EBorderRadius.all(numbers[0]); } else if (numbers.length == 2) { @@ -992,7 +997,7 @@ static BoxDecoration? getBoxDecoration(dynamic style) { return EBorderRadius.only( numbers[0], numbers[1], numbers[2], numbers[3]); } else { - throw LanguageError('borderRadius requires 1 to 4 integers'); + throw LanguageError('borderRadius requires 1 to 4 numbers'); } } return null; @@ -1039,6 +1044,25 @@ static BoxDecoration? getBoxDecoration(dynamic style) { return rtn; } + /// parse a string and return a list of doubles + static List stringToDoubles(String value, + {double? min, double? max}) { + List rtn = []; + + for (var val in value.split(RegExp(r'\s+'))) { + double? number = double.tryParse(val); + // isFinite: double.tryParse accepts "Infinity"/"1e400", which would reach + // Radius.circular and blow up in painting. + if (number != null && + number.isFinite && + (min == null || number >= min) && + (max == null || number <= max)) { + rtn.add(number); + } + } + return rtn; + } + static int? parseIntFromString(String value) { return int.tryParse(value); } diff --git a/modules/ensemble/lib/widget/avatar.dart b/modules/ensemble/lib/widget/avatar.dart index a8c923084..4b4943ff4 100644 --- a/modules/ensemble/lib/widget/avatar.dart +++ b/modules/ensemble/lib/widget/avatar.dart @@ -23,7 +23,7 @@ import 'package:yaml/yaml.dart'; class Avatar extends EnsembleWidget { static const type = 'Avatar'; - const Avatar._(super.controller, {super.key}); + const Avatar._(super.controller); factory Avatar.build(dynamic controller) => Avatar._( controller is AvatarController ? controller : AvatarController()); @@ -43,7 +43,11 @@ class AvatarController extends EnsembleBoxController { String? source; BoxFit? fit; Color? placeholderColor; + dynamic loadingWidget; ColorFilterComposite? colorFilter; + bool allowRedirect = true; + int? resizedWidth; + int? resizedHeight; bool? useGrayscaleFilter; GroupTemplate? groupTemplate; @@ -55,9 +59,7 @@ class AvatarController extends EnsembleBoxController { List passthroughSetters() => ['group-template']; @override - Map getters() { - return {}; - } + Map getters() => {}; @override Map methods() { @@ -72,12 +74,18 @@ class AvatarController extends EnsembleBoxController { 'source': (value) => source = Utils.optionalString(value), 'fit': (value) => fit = Utils.getBoxFit(value), 'placeholderColor': (value) => placeholderColor = Utils.getColor(value), + 'loadingWidget': (value) => loadingWidget = value, + 'allowRedirect': (value) => + allowRedirect = Utils.getBool(value, fallback: true), + 'resizedWidth': (value) => + resizedWidth = Utils.optionalInt(value, min: 0, max: 2000), + 'resizedHeight': (value) => + resizedHeight = Utils.optionalInt(value, min: 0, max: 2000), 'variant': (value) => variant = AvatarVariant.values.from(value), 'onTap': (func) => onTap = EnsembleAction.from(func, initiator: this), 'onTapHaptic': (value) => onTapHaptic = Utils.optionalString(value), 'group-template': (value) => setGroupAvatar(value), 'colorFilter': (value) => colorFilter = ColorFilterComposite.from(value), - }); void setGroupAvatar(dynamic groupData) { @@ -296,8 +304,17 @@ class AvatarState extends EnsembleWidgetState Widget _buildImage(String source) => framework.Image( source: source, fit: widget.controller.fit, + resizedWidth: widget.controller.resizedWidth, + resizedHeight: widget.controller.resizedHeight, colorFilter: widget.controller.colorFilter, - networkCacheManager: EnsembleImageCacheManager.instance, + loadingWidget: + getScopeManager() != null && widget.controller.loadingWidget != null + ? getScopeManager()! + .buildWidgetFromDefinition(widget.controller.loadingWidget) + : null, + cacheManager: EnsembleImageCacheManager.instanceFor( + allowRedirect: widget.controller.allowRedirect, + ), placeholderBuilder: (_, __) => ColoredBoxPlaceholder(color: widget.controller.placeholderColor), errorBuilder: (_) => _buildFallback()); diff --git a/modules/ensemble/lib/widget/button.dart b/modules/ensemble/lib/widget/button.dart index 922e10736..2fd9b2c16 100644 --- a/modules/ensemble/lib/widget/button.dart +++ b/modules/ensemble/lib/widget/button.dart @@ -130,6 +130,7 @@ class ButtonState extends EWidgetState