From 5ff97dcd6dd7e9024f58a67720f9b79f235541c5 Mon Sep 17 00:00:00 2001 From: TheNoumanDev <76642732+TheNoumanDev@users.noreply.github.com> Date: Fri, 6 Mar 2026 06:59:26 +0500 Subject: [PATCH 1/5] Added the Tv navigation support --- modules/ensemble/lib/ensemble_app.dart | 31 ++ modules/ensemble/lib/framework/device.dart | 27 ++ .../lib/framework/theme/theme_loader.dart | 66 ++- .../ensemble/lib/framework/theme_manager.dart | 10 +- .../lib/framework/tv/tv_focus_order.dart | 162 +++++++ .../lib/framework/tv/tv_focus_provider.dart | 169 +++++++ .../lib/framework/tv/tv_focus_theme.dart | 101 +++++ .../lib/framework/tv/tv_focus_widget.dart | 236 ++++++++++ modules/ensemble/lib/framework/view/page.dart | 20 + modules/ensemble/lib/layout/grid_view.dart | 19 +- .../ensemble/lib/layout/tab/base_tab_bar.dart | 268 ++++++++++- .../lib/layout/tab/tab_bar_controller.dart | 6 + modules/ensemble/lib/screen_controller.dart | 18 + modules/ensemble/lib/widget/button.dart | 2 + .../lib/widget/helpers/box_wrapper.dart | 425 ++++++++++++++++-- .../lib/widget/helpers/controllers.dart | 89 +++- .../lib/widget/helpers/form_helper.dart | 6 + .../lib/widget/helpers/input_wrapper.dart | 40 ++ 18 files changed, 1641 insertions(+), 54 deletions(-) create mode 100644 modules/ensemble/lib/framework/tv/tv_focus_order.dart create mode 100644 modules/ensemble/lib/framework/tv/tv_focus_provider.dart create mode 100644 modules/ensemble/lib/framework/tv/tv_focus_theme.dart create mode 100644 modules/ensemble/lib/framework/tv/tv_focus_widget.dart diff --git a/modules/ensemble/lib/ensemble_app.dart b/modules/ensemble/lib/ensemble_app.dart index faff5234c..0fbbb6c24 100644 --- a/modules/ensemble/lib/ensemble_app.dart +++ b/modules/ensemble/lib/ensemble_app.dart @@ -17,6 +17,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'; @@ -108,6 +109,7 @@ class EnsembleApp extends StatefulWidget { this.onAppLoad, this.forcedLocale, this.child, + this.tvFocusProvider, GlobalKey? navigatorKey, ScrollController? screenScroller, }) { @@ -139,6 +141,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(); } @@ -421,6 +429,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; } @@ -472,6 +493,16 @@ class EnsembleAppState extends State with WidgetsBindingObserver { // child: app, // ); // } + + // Wrap with TV focus provider if provided by host app + // This enables host app's focus system to manage Ensemble widgets + if (widget.tvFocusProvider != null) { + app = TVFocusProviderScope( + provider: widget.tvFocusProvider!, + child: app, + ); + } + return app; } diff --git a/modules/ensemble/lib/framework/device.dart b/modules/ensemble/lib/framework/device.dart index 74126536d..880afd14a 100644 --- a/modules/ensemble/lib/framework/device.dart +++ b/modules/ensemble/lib/framework/device.dart @@ -61,6 +61,9 @@ class Device "macOsInfo": () => DeviceMacOsInfo(), "windowsInfo": () => DeviceWindowsInfo(), + // TV detection + "isTV": () => isTV, + // @deprecated. backward compatibility DevicePlatform.web.name: () => DeviceWebInfo() }; @@ -74,6 +77,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), @@ -134,8 +138,31 @@ mixin DeviceInfoCapability { static MacOsDeviceInfo? macOsInfo; static WindowsDeviceInfo? windowsInfo; + // Android TV detection cache + static bool? _isTV; + DevicePlatform? get platform => _platform; + /// Returns true if the device is an Android TV + /// Checks for TV-specific system features + bool get isTV { + if (_isTV != null) return _isTV!; + + // Only Android devices can be TVs (for now) + if (kIsWeb || _platform != DevicePlatform.android || androidInfo == null) { + _isTV = false; + return false; + } + + // Check for TV system features + final systemFeatures = androidInfo!.systemFeatures; + _isTV = systemFeatures.contains('android.hardware.type.television') || + systemFeatures.contains('android.software.leanback') || + systemFeatures.contains('android.software.leanback_only'); + + return _isTV!; + } + /// initialize device info void initDeviceInfo() async { try { diff --git a/modules/ensemble/lib/framework/theme/theme_loader.dart b/modules/ensemble/lib/framework/theme/theme_loader.dart index 67ed7708b..53ff86f55 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:flutter/material.dart'; @@ -22,6 +23,7 @@ mixin ThemeLoader { YamlMap? colorOverrides, YamlMap? screenOverrides, YamlMap? widgetOverrides, + YamlMap? tokensOverrides, }) { if (appOverrides == null) { @@ -36,6 +38,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(); @@ -170,6 +175,7 @@ mixin ThemeLoader { loadingScreenIndicatorColor: Utils.getColor( colorOverrides?['loadingScreenIndicatorColor']), transitions: Utils.getMap(overrides?['Transitions']), + tvFocusTheme: _parseTVFocusTheme(tokensOverrides), ) ]); } @@ -505,6 +511,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: + /// focusColor: 0xFF00AAFF + /// focusBorderWidth: 3 + /// focusBorderRadius: 8 + /// focusAnimationDuration: 150 + /// ``` + TVFocusTheme? _parseTVFocusTheme(YamlMap? tokens) { + final tvTokens = tokens?['TV']; + if (tvTokens == null) return null; + + return TVFocusTheme( + focusColor: Utils.getColor(tvTokens['focusColor']), + focusBorderWidth: Utils.optionalDouble(tvTokens['focusBorderWidth']), + focusBorderRadius: Utils.optionalDouble(tvTokens['focusBorderRadius']), + focusAnimationDurationMs: + Utils.optionalInt(tvTokens['focusAnimationDuration']), + ); + } } // add more data to checkbox theme @@ -518,26 +548,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 @@ -551,6 +591,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_order.dart b/modules/ensemble/lib/framework/tv/tv_focus_order.dart new file mode 100644 index 000000000..c73de8aea --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_order.dart @@ -0,0 +1,162 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +/// Focus coordinate for TV navigation. +/// Based on flutter_pca's PageFocusOrder pattern. +/// +/// Each focusable item gets a (row, order) coordinate: +/// - [row] is the vertical position (0, 1, 2, ...) +/// - [isRowEntryPoint] marks this item as the preferred entry when entering this row +/// - [order] is the horizontal position within the row (0, 1, 2, ...) +/// - [page] is for scroll pagination (0 = start, 1 = end) +/// - [pagePixels] is for exact scroll offset +class TVFocusOrder extends FocusOrder { + /// Creates a focus order with the given coordinates. + /// [row] describes the vertical position + /// [order] describes the horizontal position within the row + /// [page] controls scrolling (0 = start, 1 = end) + /// [isRowEntryPoint] marks this as the preferred item when entering this row + const TVFocusOrder( + this.row, [ + this.order = 0, + this.page = 0, + this.pagePixels, + ]) : isRowEntryPoint = false; + + /// Creates a focus order with named parameters for optional values. + const TVFocusOrder.withOptions( + this.row, { + this.order = 0, + this.page = 0, + this.pagePixels, + this.isRowEntryPoint = false, + }); + + final double row; + final double order; + final int page; + final double? pagePixels; + + /// 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; + + /// 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(row + rowOffset, order + orderOffset, page, pagePixels); + } + + /// Request focus on the widget with this order coordinate + /// Searches within the same FocusTraversalGroup + void requestFocus(BuildContext context) { + final group = context.findAncestorWidgetOfExactType(); + final root = FocusManager.instance.rootScope; + + for (final focusNode in root.descendants) { + final focusTraversalOrder = + focusNode.context?.findAncestorWidgetOfExactType(); + + if (focusTraversalOrder?.order is TVFocusOrder) { + final gridFocusOrder = focusTraversalOrder!.order as TVFocusOrder; + if (gridFocusOrder.value == value) { + final thisGroup = + focusNode.context?.findAncestorWidgetOfExactType(); + if (thisGroup == group) { + focusNode.requestFocus(); + return; + } + } + } + } + } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'TVFocusOrder(row: $row, order: $order, page: $page)'; + } +} + +/// Node wrapper for grid building +class TVFocusOrderNode { + final FocusNode focus; + final TVFocusOrder order; + + const TVFocusOrderNode(this.focus, this.order); + + @override + String toString() => 'TVFocusOrderNode(${order.value}, ${focus.hashCode})'; + + @override + bool operator ==(Object other) => + other is TVFocusOrderNode && order.value == other.order.value; + + @override + int get hashCode => order.value.hashCode; + + /// 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; + } +} + +/// Custom traversal policy for TV focus navigation. +/// Prevents focus from escaping upward when at row 0. +class TVFocusOrderTraversalPolicy extends ReadingOrderTraversalPolicy { + /// When true, prevents navigating up from row 0 + final bool preventOutOfScopeTopTraversal; + + TVFocusOrderTraversalPolicy({ + this.preventOutOfScopeTopTraversal = true, + }); +} + +/// Focus scope that can lock focus within a region. +/// Useful for dialogs or modal content. +class TVFocusScope extends FocusScope { + /// If true, focus cannot escape this scope + final bool lockScope; + + const TVFocusScope({ + super.key, + required this.lockScope, + required super.child, + super.debugLabel, + }); +} 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..22449b47e --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_provider.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +/// Abstract interface for TV focus navigation systems. +/// +/// This allows Ensemble to integrate with a host app's focus system +/// (e.g., flutter_pca's PageFocusWidget) instead of using its own. +/// +/// When a host app provides a [TVFocusProvider], Ensemble widgets will use +/// the host's focus system, enabling seamless D-pad navigation between +/// host app content and Ensemble content. +/// +/// ## Why This Exists +/// +/// When Ensemble is embedded in a host app (like flutter_pca), both apps have +/// their own TV focus systems. Without integration, they operate as separate +/// grids with no way to navigate between them. +/// +/// By providing a [TVFocusProvider], the host app can: +/// 1. Register Ensemble widgets in its own focus grid +/// 2. Enable seamless UP/DOWN/LEFT/RIGHT navigation across the entire app +/// 3. Maintain a single source of truth for focus state +/// +/// ## Usage in Host App +/// +/// ```dart +/// // 1. Create your provider implementation +/// class MyFocusProvider implements TVFocusProvider { +/// @override +/// Widget wrapFocusable({ +/// required double row, +/// required double order, +/// required Widget child, +/// bool isRowEntryPoint = false, +/// KeyEventResult Function(FocusNode)? onBackPressed, +/// }) { +/// return PageFocusWidget( +/// focusOrder: PageFocusOrder(row, order, isRowEntryPoint: isRowEntryPoint), +/// 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. + /// - [child]: The widget to make focusable. Should contain an InkWell + /// or similar focusable widget. + /// - [onBackPressed]: Optional callback for Android TV back button. + /// + /// 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 + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + KeyEventResult Function(FocusNode node)? onBackPressed, + }); + + /// Optional: Row offset for Ensemble content. + /// + /// Ensemble's YAML-defined `tvRow` values are relative (0, 1, 2...). + /// This offset is added to create absolute positions in the host app's grid. + /// + /// Example: If host app's tab bar is at row 0, set rowOffset to 1 + /// so Ensemble content starts at row 1. + /// + /// Default: 0 (no offset) + double get rowOffset => 0; + + /// Optional: Order (horizontal) offset for Ensemble content. + /// + /// Ensemble's YAML-defined `tvOrder` values are relative (0, 1, 2...). + /// This offset is added to create absolute positions in the host app's grid. + /// + /// Example: If Sports tab is at order 5, set orderOffset to 5 + /// so navigating UP from Ensemble naturally lands on the Sports tab. + /// + /// Default: 0 (no offset) + double get orderOffset => 0; + + // ───────────────────────────────────────────────────────────────────────── + // TV Focus Styling (optional overrides from host app) + // Priority: Ensemble Theme > Provider > Default fallback + // ───────────────────────────────────────────────────────────────────────── + + /// Optional: Focus indicator border color. + /// + /// When provided, overrides Ensemble's default focus color. + /// Theme configuration takes priority over this value. + /// + /// Default: null (use theme or fallback to Color(0xFF00E676)) + Color? get focusColor => 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; + + /// 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_theme.dart b/modules/ensemble/lib/framework/tv/tv_focus_theme.dart new file mode 100644 index 000000000..24ce3619c --- /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: +/// focusColor: 0xFF00AAFF +/// focusBorderWidth: 3 +/// focusBorderRadius: 8 +/// focusAnimationDuration: 150 +/// ``` +class TVFocusTheme { + const TVFocusTheme({ + this.focusColor, + this.focusBorderWidth, + this.focusBorderRadius, + this.focusAnimationDurationMs, + }); + + /// Focus indicator border color from theme. + final Color? focusColor; + + /// 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: focusColor 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( + focusColor: other.focusColor ?? focusColor, + focusBorderWidth: other.focusBorderWidth ?? focusBorderWidth, + focusBorderRadius: other.focusBorderRadius ?? focusBorderRadius, + focusAnimationDurationMs: + other.focusAnimationDurationMs ?? focusAnimationDurationMs, + ); + } + + /// Resolves the final focus color with fallback chain. + /// + /// Priority: this.focusColor > providerColor > appPrimaryColor + /// + /// [appPrimaryColor] is typically `Theme.of(context).colorScheme.primary` + Color resolveFocusColor(Color? providerColor, Color appPrimaryColor) { + return focusColor ?? 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.focusColor == focusColor && + other.focusBorderWidth == focusBorderWidth && + other.focusBorderRadius == focusBorderRadius && + other.focusAnimationDurationMs == focusAnimationDurationMs; + } + + @override + int get hashCode => Object.hash( + focusColor, + 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..a17089e00 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_widget.dart @@ -0,0 +1,236 @@ +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Widget that wraps a focusable child with TV D-pad navigation support. +/// Based on flutter_pca's PageFocusWidget pattern. +/// +/// This widget: +/// - Intercepts arrow key events (UP/DOWN/LEFT/RIGHT) +/// - Builds a 2D grid of focusable items in the same FocusTraversalGroup +/// - Navigates between items using row/order coordinates +/// - Handles auto-scrolling when focus changes +class TVFocusWidget extends StatelessWidget { + const TVFocusWidget({ + super.key, + required this.focusOrder, + required this.child, + this.onBackPressed, + }); + + /// 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; + + @override + Widget build(BuildContext context) { + return FocusTraversalOrder( + order: focusOrder, + child: Focus( + onKeyEvent: (FocusNode node, KeyEvent event) { + if (event is KeyDownEvent) { + // Handle back button + if (event.logicalKey == LogicalKeyboardKey.goBack) { + final result = onBackPressed?.call(node); + if (result != null) { + return result; + } + } + + // Handle arrow keys + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_moveFocus(context, node, yOffset: 1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_moveFocus(context, node, yOffset: -1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_moveFocus(context, node, xOffset: 1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_moveFocus(context, node, xOffset: -1)) { + return KeyEventResult.handled; + } + } + } + return KeyEventResult.ignored; + }, + // This Focus is for key handling only, not for actual focus + canRequestFocus: false, + child: child, + ), + ); + } + + /// Move focus in the specified direction. + /// Returns true if focus was moved, false if at boundary. + bool _moveFocus( + BuildContext context, + FocusNode current, { + int yOffset = 0, + int xOffset = 0, + }) { + // 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; + + // Check if trying to exit at top boundary (UP at row 0) + if (yOffset == -1 && focusOrder.row == 0) { + // Check if policy blocks escape + if (focusTraversalGroup?.policy is TVFocusOrderTraversalPolicy) { + final policy = focusTraversalGroup!.policy as TVFocusOrderTraversalPolicy; + if (policy.preventOutOfScopeTopTraversal) { + return true; // Block the event + } + } + // No blocking policy - let event propagate + return false; + } + + // Collect all focusable items in the same FocusTraversalGroup + final root = FocusManager.instance.rootScope; + final inScope = {}; + + for (final focusNode in root.descendants) { + // Check if this node is mounted and has context + if (focusNode.context == null) continue; + + // Check if in same FocusTraversalGroup + final nodeGroup = + focusNode.context?.findAncestorWidgetOfExactType(); + if (nodeGroup != focusTraversalGroup) continue; + + // Get the TVFocusOrder for this node + final focusTraversalOrder = + focusNode.context?.findAncestorWidgetOfExactType(); + if (focusTraversalOrder?.order is TVFocusOrder) { + final order = focusTraversalOrder!.order as TVFocusOrder; + inScope.add(TVFocusOrderNode(focusNode, order)); + } + } + + if (inScope.isEmpty) { + return false; + } + + // Build 2D grid from collected items + final grid = TVFocusOrderNode.buildGrid(inScope); + 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; + } + + 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); + // When moving to a new row, look for an entry point (e.g., selected tab) + // If no entry point found, go to the first item (order 0) + newX = _findRowEntryPoint(grid[newY]); + } 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) { + // Handle scope locking + if (lockScope) { + // Focus would stay the same, but we're locked - block the event + return true; + } + + // At boundary - let event propagate to parent + 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 0 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, default to first item + return 0; + } + + /// Find the nearest row in the specified direction. + /// Uses actual tvRow values, not array indices. + int _findNearestRow( + List> grid, + int currentY, + double currentRow, + int direction, + ) { + if (direction > 0) { + // Moving down: find first row with tvRow > currentRow + for (int i = currentY + 1; i < grid.length; i++) { + final rowValue = grid[i].firstOrNull?.order.row; + if (rowValue != null && rowValue > currentRow) { + return i; + } + } + // No row found below, stay at current + return currentY; + } else { + // Moving up: find last row with tvRow < currentRow + for (int i = currentY - 1; i >= 0; i--) { + final rowValue = grid[i].firstOrNull?.order.row; + if (rowValue != null && rowValue < currentRow) { + return i; + } + } + // No row found above, stay at current + return currentY; + } + } +} diff --git a/modules/ensemble/lib/framework/view/page.dart b/modules/ensemble/lib/framework/view/page.dart index 403300628..41131c353 100644 --- a/modules/ensemble/lib/framework/view/page.dart +++ b/modules/ensemble/lib/framework/view/page.dart @@ -24,6 +24,9 @@ import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:ensemble/widget/helpers/unfocus.dart'; import 'package:ensemble/framework/bindings.dart'; +import 'package:ensemble/framework/device.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; import 'package:flutter/material.dart'; class SinglePageController extends WidgetController { @@ -766,6 +769,23 @@ class PageState extends State rtn = HasSelectableText(child: rtn); } + // TV D-pad Navigation using flutter_pca style coordinate-based navigation + // Only add our own FocusTraversalGroup if no external provider is managing focus + // When external provider exists (e.g., PageFocusProvider from host app), + // we skip this wrapper so Ensemble items participate in the host app's focus grid + if (Device().isTV) { + final hasExternalProvider = TVFocusProviderScope.maybeOf(context) != null; + if (hasExternalProvider) { + debugPrint('[TV Focus] External provider detected - skipping FocusTraversalGroup wrapper'); + } else { + debugPrint('[TV Focus] Using FocusTraversalGroup with TVFocusOrderTraversalPolicy'); + rtn = FocusTraversalGroup( + policy: TVFocusOrderTraversalPolicy(), + child: rtn, + ); + } + } + // if backgroundImage is set, put it outside of the Scaffold so // keyboard sliding up (when entering value) won't resize the background if (backgroundImage != null) { diff --git a/modules/ensemble/lib/layout/grid_view.dart b/modules/ensemble/lib/layout/grid_view.dart index 7b7501864..3fc2725b5 100644 --- a/modules/ensemble/lib/layout/grid_view.dart +++ b/modules/ensemble/lib/layout/grid_view.dart @@ -299,15 +299,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/tab/base_tab_bar.dart b/modules/ensemble/lib/layout/tab/base_tab_bar.dart index 262ffbb0c..0486109a3 100644 --- a/modules/ensemble/lib/layout/tab/base_tab_bar.dart +++ b/modules/ensemble/lib/layout/tab/base_tab_bar.dart @@ -1,7 +1,9 @@ -import 'package:ensemble/action/haptic_action.dart'; +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/extensions.dart'; import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_widget.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/widget/widget.dart'; import 'package:ensemble/layout/tab/tab_bar_controller.dart'; @@ -29,6 +31,12 @@ abstract class BaseTabBarState extends EWidgetState /// build the Tab Bar navigation part Widget buildTabBar() { + // TV Navigation: Use custom focusable tab buttons instead of Flutter TabBar + // This follows flutter_pca's pattern where each tab is an individually focusable button + if (Device().isTV) { + return _buildTVTabBar(); + } + TextStyle? tabStyle = TextStyle( fontSize: widget.controller.tabFontSize?.toDouble(), fontWeight: widget.controller.tabFontWeight); @@ -106,6 +114,100 @@ abstract class BaseTabBarState extends EWidgetState return tabBar; } + /// Build TV-specific tab bar with individually focusable buttons. + /// Uses flutter_pca-style TVFocusOrder coordinates for navigation. + /// + /// If tvRow is set on the controller, tabs participate in the main page focus grid. + /// Otherwise, tabs are wrapped in their own FocusTraversalGroup. + Widget _buildTVTabBar() { + final items = widget.controller.items; + final activeColor = widget.controller.activeTabColor ?? + Theme.of(context).colorScheme.primary; + final inactiveColor = widget.controller.inactiveTabColor ?? Colors.black87; + final indicatorColor = widget.controller.indicatorColor ?? activeColor; + final backgroundColor = widget.controller.tabBackgroundColor; + final indicatorThickness = + widget.controller.indicatorThickness?.toDouble() ?? 2; + + // If tvOptions.row is set, tabs participate in main page grid at that row + // Otherwise, tabs use row 0 in an isolated FocusTraversalGroup + final tvRow = widget.controller.tvOptions?.row; + final tabRow = tvRow ?? 0.0; + + debugPrint('[TV TabBar] Building ${items.length} tab buttons (tvRow=${tvRow ?? "isolated"})'); + + // 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) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(items.length, (index) { + final tabItem = items[index]; + + return _TVTabButton( + key: ValueKey('tv_tab_$index'), + tabItem: tabItem, + index: index, + tabRow: tabRow, + isSelected: tabController.index == index, + autofocus: index == 0, // First tab gets autofocus + activeColor: activeColor, + inactiveColor: inactiveColor, + indicatorColor: indicatorColor, + indicatorThickness: indicatorThickness, + tabFontSize: widget.controller.tabFontSize?.toDouble(), + tabFontWeight: widget.controller.tabFontWeight, + tabPadding: widget.controller.tabPadding, + onTap: () { + debugPrint('[TV TabBar] Tab $index tapped, switching content'); + tabController.animateTo(index); + onTabChanged(index); + }, + ); + }), + ), + ); + }, + ); + + // If tvRow is NOT set, wrap tabs in their own FocusTraversalGroup + // to isolate them from page content navigation. + // If tvRow IS set, tabs participate in the main page focus grid. + if (tvRow == null) { + tabBar = FocusTraversalGroup( + policy: TVFocusOrderTraversalPolicy(), + child: tabBar, + ); + } + + if (backgroundColor != null) { + tabBar = ColoredBox(color: backgroundColor, child: tabBar); + } + + if (widget.controller.borderRadius != null) { + final borderRadius = widget.controller.borderRadius?.getValue(); + tabBar = Container( + decoration: BoxDecoration( + border: Border.all( + color: widget.controller.borderColor ?? Colors.transparent, + width: (widget.controller.borderWidth ?? 0.0).toDouble(), + ), + borderRadius: borderRadius ?? BorderRadius.zero, + ), + child: ClipRRect( + borderRadius: borderRadius ?? BorderRadius.zero, + child: tabBar + ), + ); + } + + return tabBar; + } + List _buildTabs(List items) { List tabItems = []; for (final tabItem in items) { @@ -135,3 +237,167 @@ abstract class BaseTabBarState extends EWidgetState mixin TabBarAction on EWidgetState { void changeTab(int index); } + +/// TV-specific focusable tab button using flutter_pca-style navigation. +/// Each tab uses TVFocusOrder coordinates. +/// If TabBar has tvRow set, tabs use that row in the main page grid. +/// Otherwise, tabs use row 0 within an isolated FocusTraversalGroup. +class _TVTabButton extends StatefulWidget { + const _TVTabButton({ + super.key, + required this.tabItem, + required this.index, + required this.tabRow, + required this.isSelected, + required this.activeColor, + required this.inactiveColor, + required this.indicatorColor, + required this.indicatorThickness, + required this.onTap, + this.autofocus = false, + this.tabFontSize, + this.tabFontWeight, + this.tabPadding, + }); + + final TabItem tabItem; + final int index; + final double tabRow; + final bool isSelected; + final bool autofocus; + final Color activeColor; + final Color inactiveColor; + final Color indicatorColor; + final double indicatorThickness; + final VoidCallback onTap; + final double? tabFontSize; + final FontWeight? tabFontWeight; + final EdgeInsets? tabPadding; + + @override + State<_TVTabButton> createState() => _TVTabButtonState(); +} + +class _TVTabButtonState extends State<_TVTabButton> { + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'TVTabButton_${widget.index}'); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @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: () { + debugPrint('[TV TabBar] Tab ${widget.index} tapped'); + widget.onTap(); + }, + child: Builder( + builder: (builderContext) { + // Get focus state from InkWell's Focus + final hasFocus = Focus.maybeOf(builderContext)?.hasFocus ?? false; + if (hasFocus) { + debugPrint('[TV TabBar] Tab ${widget.index} focused (isSelected=${widget.isSelected})'); + } + return Container( + padding: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Tab content (icon + label) + _buildTabContent(context, hasFocus), + const SizedBox(height: 4), + // Indicator line (shows when selected or focused) + AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: widget.indicatorThickness, + width: hasFocus ? 24 : (widget.isSelected ? 16 : 0), + decoration: BoxDecoration( + color: hasFocus + ? Colors.blue + : widget.isSelected + ? widget.indicatorColor + : Colors.transparent, + borderRadius: BorderRadius.circular(widget.indicatorThickness / 2), + ), + ), + ], + ), + ); + }, + ), + ); + + // Wrap with TVFocusWidget for D-pad navigation. + // Uses tabRow from TabBar controller (either tvRow from YAML or 0 for isolated group). + // Order = index for left/right navigation. + // The selected tab is marked as entry point so it gets focus when entering the row. + return TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + widget.tabRow, + order: widget.index.toDouble(), + isRowEntryPoint: widget.isSelected, // selected tab is the entry point + ), + child: inkWell, + ); + } + + Widget _buildTabContent(BuildContext context, bool isFocused) { + final textColor = isFocused + ? Colors.white + : widget.isSelected + ? widget.activeColor + : widget.inactiveColor; + + final textStyle = TextStyle( + fontSize: widget.tabFontSize ?? 14, + fontWeight: widget.tabFontWeight ?? (widget.isSelected ? FontWeight.w600 : FontWeight.normal), + color: textColor, + ); + + // 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/tab_bar_controller.dart b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart index cf629ad74..730324960 100644 --- a/modules/ensemble/lib/layout/tab/tab_bar_controller.dart +++ b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart @@ -25,6 +25,11 @@ class TabBarController extends BoxController { Color? dividerColor; int? indicatorThickness; + /// TV Navigation: The row position for tab buttons in the focus grid. + /// If set, tabs participate in the main page focus grid at this row. + /// If not set, tabs are in an isolated focus group. + double? tvRow; + EnsembleAction? onTabSelection; String? onTabSelectionHaptic; TabBarAction? tabBarAction; @@ -65,6 +70,7 @@ class TabBarController extends BoxController { var setters = super.getBaseSetters(); setters.addAll({ 'items': (values) => items = values, + 'tvRow': (value) => tvRow = Utils.optionalDouble(value), }); return setters; } diff --git a/modules/ensemble/lib/screen_controller.dart b/modules/ensemble/lib/screen_controller.dart index 5dff842a6..36e9e03d7 100644 --- a/modules/ensemble/lib/screen_controller.dart +++ b/modules/ensemble/lib/screen_controller.dart @@ -565,6 +565,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. + // Note: We intentionally do NOT wrap with TVFocusProviderScope here because: + // - It can interfere with TextInput and TabBar focus handling + // - Ensemble's built-in TVFocusWidget will be used instead, which works better + // for widgets that have their own focus management (TextInput, TabBar) + // - The Theme wrapper provides EnsembleThemeExtension which has TV focus colors + if (asExternal) { + // Get theme from EnsembleThemeManager + final ensembleThemeData = EnsembleThemeManager().currentTheme()?.appThemeData; + if (ensembleThemeData != null) { + screenWidget = Theme( + data: ensembleThemeData, + child: screenWidget, + ); + } + } + Map? defaultTransitionOptions = Theme.of(context).extension()?.transitions ?? {}; diff --git a/modules/ensemble/lib/widget/button.dart b/modules/ensemble/lib/widget/button.dart index f7ca53524..92af6fc84 100644 --- a/modules/ensemble/lib/widget/button.dart +++ b/modules/ensemble/lib/widget/button.dart @@ -134,6 +134,7 @@ class ButtonState extends EWidgetState