diff --git a/CHANGELOG.md b/CHANGELOG.md index 38494f0c1..114539170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,113 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2026-03-30 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.38-beta.2`](#ensemble---v1238-beta2) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.38-beta.2` + + - Releasing new beta version 1.2.38.2 + + +## 2026-03-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.38-beta.1`](#ensemble---v1238-beta1) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.38-beta.1` + + - **FIX**(phone_contact): replace RuntimeError with debugPrint for missing contact photo. ([b36b399d](https://github.com/ensembleUI/ensemble/commit/b36b399d91fad26e46e14f0845c624a3f8b768c9)) + - **FIX**(firestore_types): handle FirestoreTimestamp conversion in EnsembleFieldValue class. ([a4e8dba0](https://github.com/ensembleUI/ensemble/commit/a4e8dba0142250eee12b09fd012ae85e5ac18f2f)) + - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) + - **FEAT**(action): add ActionType.executeAction to ActionInvokable class. ([b5cc5a4a](https://github.com/ensembleUI/ensemble/commit/b5cc5a4af5ac95b0ed4971349f5cf5ab9a481672)) + - **FEAT**(lottie): add custom Lottie decoder for .lottie file ext support. ([cc73e7cf](https://github.com/ensembleUI/ensemble/commit/cc73e7cf87475a7e538b0c88a099a90c5c63af21)) + - **FEAT**(env): enhance environment variable loading and parsing. ([b7666ceb](https://github.com/ensembleUI/ensemble/commit/b7666ceb292427ad24445cc3080a68e9aca8c47a)) + - **FEAT**(cdn_provider): add runtime translation refresh and testing capabilities. ([c9ba1fd2](https://github.com/ensembleUI/ensemble/commit/c9ba1fd23c34031c96e2248f1b05cf2ba2b4bc88)) + - **FEAT**(secure_storage): enhance secure storage actions with optional encryption parameters. ([dee0bb57](https://github.com/ensembleUI/ensemble/commit/dee0bb571152e95b4cdc658924b2399c6b4f58b4)) + + ## 2026-03-18 ### Changes @@ -257,6 +364,58 @@ Packages with dependency updates only: - **FEAT**(actions): introduce reusable action execution framework. ([482d7de9](https://github.com/ensembleUI/ensemble/commit/482d7de922433bb41a282cfdd018f13866fe511f)) +## 2026-03-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.35-beta.1`](#ensemble---v1235-beta1) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.35-beta.1` + + - **FIX**(execute_action): update payload key from 'action' to 'body' in ExecuteActionAction class. ([7e1b8466](https://github.com/ensembleUI/ensemble/commit/7e1b846611b4da27f21fa3474d8a6de05b40b768)) + - **FIX**(page_model): add 'Actions' to the list of available types in PageModel. ([6dc07f06](https://github.com/ensembleUI/ensemble/commit/6dc07f06e447c5cdbf49be6f29a54e74fa6987e5)) + - **FEAT**(actions): introduce reusable action execution framework. ([482d7de9](https://github.com/ensembleUI/ensemble/commit/482d7de922433bb41a282cfdd018f13866fe511f)) + + ## 2026-03-02 ### Changes diff --git a/modules/adobe_analytics/pubspec.yaml b/modules/adobe_analytics/pubspec.yaml index 559ef868a..4254a3432 100644 --- a/modules/adobe_analytics/pubspec.yaml +++ b/modules/adobe_analytics/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/ensemble flutter_aepcore: ^5.0.0 diff --git a/modules/auth/pubspec.yaml b/modules/auth/pubspec.yaml index be665a386..8e0158f43 100644 --- a/modules/auth/pubspec.yaml +++ b/modules/auth/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/ensemble ensemble_ts_interpreter: ^1.0.7 diff --git a/modules/bracket/pubspec.yaml b/modules/bracket/pubspec.yaml index 42842f05d..3625b1f7d 100644 --- a/modules/bracket/pubspec.yaml +++ b/modules/bracket/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/ensemble dev_dependencies: diff --git a/modules/camera/pubspec.yaml b/modules/camera/pubspec.yaml index 664c7655b..60a58d047 100644 --- a/modules/camera/pubspec.yaml +++ b/modules/camera/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/ensemble ensemble_ts_interpreter: ^1.0.7 @@ -23,7 +23,7 @@ dependencies: ensemble_qr_scanner: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/qr_scanner collection: ^1.17.1 diff --git a/modules/chat/pubspec.yaml b/modules/chat/pubspec.yaml index 6000b3955..0c9684602 100644 --- a/modules/chat/pubspec.yaml +++ b/modules/chat/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/ensemble ensemble_ts_interpreter: ^1.0.7 diff --git a/modules/connect/pubspec.yaml b/modules/connect/pubspec.yaml index f8ac678cb..1ab6bd53a 100644 --- a/modules/connect/pubspec.yaml +++ b/modules/connect/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/ensemble plaid_flutter: ^3.1.2 diff --git a/modules/contacts/pubspec.yaml b/modules/contacts/pubspec.yaml index 769fc9927..54cfdf5aa 100644 --- a/modules/contacts/pubspec.yaml +++ b/modules/contacts/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/ensemble flutter_contacts: ^1.1.7+1 diff --git a/modules/deeplink/pubspec.yaml b/modules/deeplink/pubspec.yaml index 56169735a..72a91e8ca 100644 --- a/modules/deeplink/pubspec.yaml +++ b/modules/deeplink/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: ensemble: git: url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.38 + ref: ensemble-v1.2.38-beta.2 path: modules/ensemble flutter_branch_sdk: ^7.0.1 diff --git a/modules/ensemble/CHANGELOG.md b/modules/ensemble/CHANGELOG.md index 0ed7e7fe3..812563a03 100644 --- a/modules/ensemble/CHANGELOG.md +++ b/modules/ensemble/CHANGELOG.md @@ -1,3 +1,18 @@ +## 1.2.38-beta.2 + + - Releasing new beta version 1.2.38.2 + +## 1.2.38-beta.1 + + - **FIX**(phone_contact): replace RuntimeError with debugPrint for missing contact photo. ([b36b399d](https://github.com/ensembleUI/ensemble/commit/b36b399d91fad26e46e14f0845c624a3f8b768c9)) + - **FIX**(firestore_types): handle FirestoreTimestamp conversion in EnsembleFieldValue class. ([a4e8dba0](https://github.com/ensembleUI/ensemble/commit/a4e8dba0142250eee12b09fd012ae85e5ac18f2f)) + - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) + - **FEAT**(action): add ActionType.executeAction to ActionInvokable class. ([b5cc5a4a](https://github.com/ensembleUI/ensemble/commit/b5cc5a4af5ac95b0ed4971349f5cf5ab9a481672)) + - **FEAT**(lottie): add custom Lottie decoder for .lottie file ext support. ([cc73e7cf](https://github.com/ensembleUI/ensemble/commit/cc73e7cf87475a7e538b0c88a099a90c5c63af21)) + - **FEAT**(env): enhance environment variable loading and parsing. ([b7666ceb](https://github.com/ensembleUI/ensemble/commit/b7666ceb292427ad24445cc3080a68e9aca8c47a)) + - **FEAT**(cdn_provider): add runtime translation refresh and testing capabilities. ([c9ba1fd2](https://github.com/ensembleUI/ensemble/commit/c9ba1fd23c34031c96e2248f1b05cf2ba2b4bc88)) + - **FEAT**(secure_storage): enhance secure storage actions with optional encryption parameters. ([dee0bb57](https://github.com/ensembleUI/ensemble/commit/dee0bb571152e95b4cdc658924b2399c6b4f58b4)) + ## 1.2.38 - **FEAT**(env): enhance environment variable loading and parsing. ([b7666ceb](https://github.com/ensembleUI/ensemble/commit/b7666ceb292427ad24445cc3080a68e9aca8c47a)) @@ -22,6 +37,12 @@ - **FIX**(page_model): add 'Actions' to the list of available types in PageModel. ([6dc07f06](https://github.com/ensembleUI/ensemble/commit/6dc07f06e447c5cdbf49be6f29a54e74fa6987e5)) - **FEAT**(actions): introduce reusable action execution framework. ([482d7de9](https://github.com/ensembleUI/ensemble/commit/482d7de922433bb41a282cfdd018f13866fe511f)) +## 1.2.35-beta.1 + + - **FIX**(execute_action): update payload key from 'action' to 'body' in ExecuteActionAction class. ([7e1b8466](https://github.com/ensembleUI/ensemble/commit/7e1b846611b4da27f21fa3474d8a6de05b40b768)) + - **FIX**(page_model): add 'Actions' to the list of available types in PageModel. ([6dc07f06](https://github.com/ensembleUI/ensemble/commit/6dc07f06e447c5cdbf49be6f29a54e74fa6987e5)) + - **FEAT**(actions): introduce reusable action execution framework. ([482d7de9](https://github.com/ensembleUI/ensemble/commit/482d7de922433bb41a282cfdd018f13866fe511f)) + ## 1.2.34 - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) 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..b98a9a4c8 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_provider.dart @@ -0,0 +1,178 @@ +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; + + /// 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; + + /// 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..ccd0ae7e9 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_widget.dart @@ -0,0 +1,249 @@ +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); + // First, try to find an explicit entry point in the new row + final entryPointIndex = _findRowEntryPoint(grid[newY]); + if (entryPointIndex != -1) { + // Entry point found, use it + newX = entryPointIndex; + } else { + // No entry point: preserve current column position (order) + // Try to find the same order value in the new row + final sameOrderIndex = grid[newY].indexWhere((node) => node.order.order == focusOrder.order); + if (sameOrderIndex != -1) { + newX = sameOrderIndex; + } else { + // Same order not found, clamp to available range + newX = x.clamp(0, grid[newY].length - 1); + } + } + } else { + // Horizontal movement: stay on same row, find nearest order + newY = y; + final targetOrder = focusOrder.order + xOffset; + final nX = grid[y].indexWhere((node) => node.order.order == targetOrder); + if (nX != -1) { + newX = nX; + } else { + // Clamp to row boundaries + newX = (x + xOffset).clamp(0, grid[y].length - 1); + } + } + + final oldTarget = grid[y][x].focus; + final target = grid[newY][newX].focus; + + // Check if we're at a boundary (focus wouldn't move) + if (oldTarget == target) { + // 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 -1 if none found. + int _findRowEntryPoint(List row) { + for (int i = 0; i < row.length; i++) { + if (row[i].order.isRowEntryPoint) { + return i; + } + } + // No entry point found + return -1; + } + + /// Find the nearest row in the specified direction. + /// Uses actual tvRow values, not array indices. + int _findNearestRow( + List> grid, + int currentY, + double currentRow, + int direction, + ) { + if (direction > 0) { + // Moving down: find first row with tvRow > currentRow + for (int i = currentY + 1; i < grid.length; i++) { + final rowValue = grid[i].firstOrNull?.order.row; + if (rowValue != null && rowValue > currentRow) { + return i; + } + } + // No row found below, stay at current + return currentY; + } else { + // Moving up: find last row with tvRow < currentRow + for (int i = currentY - 1; i >= 0; i--) { + final rowValue = grid[i].firstOrNull?.order.row; + if (rowValue != null && rowValue < currentRow) { + return i; + } + } + // No row found above, stay at current + return currentY; + } + } +} diff --git a/modules/ensemble/lib/framework/view/page.dart b/modules/ensemble/lib/framework/view/page.dart index 12cb80059..bfb21397a 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 { @@ -767,6 +770,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 cd7f02fd1..678d87414 100644 --- a/modules/ensemble/lib/screen_controller.dart +++ b/modules/ensemble/lib/screen_controller.dart @@ -569,6 +569,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