diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f4919b1..aed84a63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,83 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2026-06-05 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.38-beta.7`](#ensemble---v1238-beta7) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---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_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_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `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.7` + + - **REFACTOR**(tabbar): remove useIndexedTab setter duplication from TabBarController. ([faafd299](https://github.com/ensembleUI/ensemble/commit/faafd2996c3f0a9b9609acfba4e3286165e2ca80)) + - **REFACTOR**(cdn): improve secret management and artifact handling in CdnDefinitionProvider. ([a2c87792](https://github.com/ensembleUI/ensemble/commit/a2c877925a4d2ac489aab8c5a89f3e66e4dd5a98)) + - **FIX**(upload): scope cancelAll to upload Workmanager tags only. ([4d763ac4](https://github.com/ensembleUI/ensemble/commit/4d763ac4c883363a96a6a5b8b3c264bf8fb3f0b3)) + - **FIX**(upload): schedule every background batch with unique Workmanager names. ([33829f9b](https://github.com/ensembleUI/ensemble/commit/33829f9b49da513c587a2e603609cf8d1af49723)) + - **FIX**(navigation): clamp navigateViewGroup index before PageController.jumpToPage. ([0ce6723a](https://github.com/ensembleUI/ensemble/commit/0ce6723a14095823fb5d2f1ba3938c45f76c289d)) + - **FIX**(upload): complete cancelAll when some tasks are already completed. ([93cfc387](https://github.com/ensembleUI/ensemble/commit/93cfc387e700a5a1cb1c327b85f68a8ac56011d2)) + - **FIX**(storage): defer binding dispatches until public storage clear completes. ([8c2a52de](https://github.com/ensembleUI/ensemble/commit/8c2a52de1e84c078ff01905c3bce07428f5dc5ba)) + - **FIX**(listview): sync ListViewCore scroll controller when parent swaps it. ([c792a8a6](https://github.com/ensembleUI/ensemble/commit/c792a8a673196a1ae2eedf92ef488be438644602)) + - **FIX**(page): cancel header timers and dedupe storage event listeners. ([2c45e427](https://github.com/ensembleUI/ensemble/commit/2c45e4273678ff91007d486137e264dedc9c2251)) + - **FIX**(navigation): clamp ViewGroup tab index when payloads shrink. ([2b62b4a2](https://github.com/ensembleUI/ensemble/commit/2b62b4a2072862c27e9292ca0d38cefa1d1d9fad)) + - **FIX**(security): block path traversal in local bundled screen resolution. ([b317c925](https://github.com/ensembleUI/ensemble/commit/b317c9256d96cd01f3183866d3f5e38bfe9b6ff7)) + - **FIX**(layout): restore scroll controller when leaving footer scope. ([9f20ea43](https://github.com/ensembleUI/ensemble/commit/9f20ea43ddb389d6a718122dc01240fc5d3d3932)) + - **FIX**(listview): dispose owned scroll controller. ([e5d1c101](https://github.com/ensembleUI/ensemble/commit/e5d1c101080f49ac0596f09d93c1d2df4e4dff22)) + - **FIX**(security): reject unsafe screen selectors in remote definition fetches. ([db7de5b2](https://github.com/ensembleUI/ensemble/commit/db7de5b28f45c0dd2438ff7d0e1bea61bba1da6d)) + - **FIX**(security): sanitize saveFile names before writing to storage. ([a3db4674](https://github.com/ensembleUI/ensemble/commit/a3db467427bd473e3b1a02c113369bcdaa75b6a4)) + - **FIX**(security): stop WebView from bypassing TLS and unsafe-browsing defaults. ([a51ba979](https://github.com/ensembleUI/ensemble/commit/a51ba979ddb12fa3cb48af0f436e5a1cb1057fc7)) + - **FIX**(device): update screenOrientation to use enum name for clarity. ([41b9dee0](https://github.com/ensembleUI/ensemble/commit/41b9dee0c974a06882db5edbd1219084ad81fc9c)) + - **FIX**(cdn): reset invalid manifest cache state. ([4e060421](https://github.com/ensembleUI/ensemble/commit/4e060421ebf7ba2121284ba93c825db7370b8f2d)) + - **FEAT**(device): streamline MediaQuery capability and add device metric notifications. ([991650b6](https://github.com/ensembleUI/ensemble/commit/991650b6666e615eca2e9b7f8418af847a08d3d0)) + - **FEAT**(tab): fix persistentTabBar behavior with listview. ([ed51255a](https://github.com/ensembleUI/ensemble/commit/ed51255a0b8456c9ec3654f6ddce1e315cae9d77)) + - **FEAT**(tabbar): add indexed tab mode with on-demand tab building and caching. ([93cd430b](https://github.com/ensembleUI/ensemble/commit/93cd430b0b700ae123ddf378f627ab884067f515)) + - **FEAT**(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management. ([42669188](https://github.com/ensembleUI/ensemble/commit/42669188ea423c9dba5beac449ee38853f01b88b)) + - **FEAT**(tab): add persistentTabBar option for keep the tab pinned. ([bbafb0d1](https://github.com/ensembleUI/ensemble/commit/bbafb0d1c3be1f321cbe92f3cc0d8598e476dde0)) + - **DOCS**(ensemble): document storage.clear and multipart upload paths. ([d88fb624](https://github.com/ensembleUI/ensemble/commit/d88fb624b68136d3f006eac5ea7c6bc78752e1e2)) + - **DOCS**(ensemble): document runtime security and device metric bindings. ([b7087842](https://github.com/ensembleUI/ensemble/commit/b7087842440ae498e11e1f088ddbfcc3f8d7dc5b)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + - **DOCS**: move layout widget notes out of package readme. ([fc901707](https://github.com/ensembleUI/ensemble/commit/fc9017078bd2147c6486352e886a347a04ef6dcc)) + - **DOCS**: document layout widget scroll and tab behavior. ([ba64173e](https://github.com/ensembleUI/ensemble/commit/ba64173e4433c49d6907c78494970639d5a93748)) + + ## 2026-06-03 ### Changes @@ -276,6 +353,158 @@ Packages with dependency updates only: - **FEAT**(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management. ([42669188](https://github.com/ensembleUI/ensemble/commit/42669188ea423c9dba5beac449ee38853f01b88b)) +## 2026-05-22 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.38-beta.6`](#ensemble---v1238-beta6) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---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_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---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_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_file_manager` - `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.6` + + - releaseing new beta version + + +## 2026-05-20 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.38-beta.5`](#ensemble---v1238-beta5) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---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_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `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.5` + + - Releasing new beta version for TV + + +## 2026-04-23 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.38-beta.4`](#ensemble---v1238-beta4) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---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_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---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_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.38-beta.4` + + - **FIX**(incorrect header format): explicit convertion of header entry into string. ([84ead788](https://github.com/ensembleUI/ensemble/commit/84ead788a7cd0b1aee50292eabf07bc8ae3c490d)) + - **FEAT**(image): enhance header evaluation for dynamic HTTP headers in image requests. ([e24544fe](https://github.com/ensembleUI/ensemble/commit/e24544fe2587a4119f6c4a8242e9b51948eef57c)) + - **FEAT**(image): add support for custom HTTP headers in image requests. ([3a304a2c](https://github.com/ensembleUI/ensemble/commit/3a304a2cd01a2af6046a327a9380cf259cb1e37e)) + + ## 2026-04-06 ### Changes @@ -333,6 +562,164 @@ Packages with dependency updates only: - **FEAT**(lottie): add custom Lottie decoder for .lottie file ext support. ([cc73e7cf](https://github.com/ensembleUI/ensemble/commit/cc73e7cf87475a7e538b0c88a099a90c5c63af21)) +## 2026-04-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.38-beta.3`](#ensemble---v1238-beta3) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`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_contacts` - `v0.0.1+1`](#ensemble_contacts---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_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `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.3` + + - **FIX**(invoke_api_action): handle FirestoreResponse in error handling. ([c8cb1c7b](https://github.com/ensembleUI/ensemble/commit/c8cb1c7b1bb97405bc61893338b1aeab53838a8f)) + - **FEAT**(dotenv): implement dotenv bundle parsing and refactor config loading. ([0a987c94](https://github.com/ensembleUI/ensemble/commit/0a987c94aef06e2b220fca85c1d8f43c707fd506)) + + +## 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 @@ -587,6 +974,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/docs/TV_DEVELOPER_GUIDE.md b/docs/TV_DEVELOPER_GUIDE.md new file mode 100644 index 000000000..914730252 --- /dev/null +++ b/docs/TV_DEVELOPER_GUIDE.md @@ -0,0 +1,1474 @@ +# Ensemble TV Complete Guide + +Comprehensive documentation for TV/D-pad navigation in the Ensemble framework, including YAML patterns, framework architecture, and flutter_pca integration. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Architecture](#2-architecture) +3. [tvOptions Property Reference](#3-tvoptions-property-reference) +4. [YAML Patterns & Examples](#4-yaml-patterns--examples) +5. [Carousel TV Implementation](#5-carousel-tv-implementation) +6. [ListView Scrollbar](#6-listview-scrollbar) +7. [Host App Integration (flutter_pca)](#7-host-app-integration-flutter_pca) +8. [Focus Styling](#8-focus-styling) +9. [Common UI Patterns](#9-common-ui-patterns) +10. [Pitfalls to Avoid](#10-pitfalls-to-avoid) +11. [Testing Checklist](#11-testing-checklist) +12. [Troubleshooting](#12-troubleshooting) + +--- + +## 1. Overview + +The Ensemble framework provides comprehensive TV support through: + +- **2D Grid Navigation** - Row/order based focus traversal for D-pad +- **Pluggable Provider Pattern** - Host apps inject their own focus system +- **Netflix-Style Scrolling** - Fixed focus position with auto-scroll +- **Flexible Styling** - Multiple override levels for focus indicators +- **Carousel Support** - Horizontal slide navigation with autoplay control + +### Key Concepts + +| Concept | Description | +| ----------------------- | ------------------------------------------------------ | +| **Row** | Vertical position in focus grid (0, 1, 2, ...) | +| **Order** | Horizontal position within a row (0, 1, 2, ...) | +| **Entry Point** | Preferred focus target when entering a row | +| **Fixed Focus Scroll** | Netflix-style scrolling where focused item stays fixed | +| **Delegate Navigation** | Passing key events to parent (for carousels) | + +--- + +## 2. Architecture + +### Core TV Framework Files + +| File | Purpose | +| -------------------------- | ------------------------------------------------ | +| `tv_focus_provider.dart` | Abstract interface for host app integration | +| `tv_focus_widget.dart` | Built-in D-pad navigation handler with edge handlers | +| `tv_focus_order.dart` | Focus coordinates (row/order) and TVFocusScope | +| `tv_focus_theme.dart` | Styling configuration | +| `tv_scrollbar_widget.dart` | Focusable scrollbar for ListView on TV | + +### Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Host App (flutter_pca) │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ TV Top Navigation Bar │ │ +│ │ [Home] [Live] [Movies] [Series] [Sports] [Apps] │ │ +│ │ ↑ Order 5 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Sports Tab Content │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ TVFocusProviderScope │ │ │ +│ │ │ ┌───────────────────────────────────────────────┐ │ │ │ +│ │ │ │ PageFocusProvider │ │ │ │ +│ │ │ │ - rowOffset: 1.0 (below tab bar) │ │ │ │ +│ │ │ │ - orderOffset: 5.0 (Sports tab align) │ │ │ │ +│ │ │ └───────────────────────────────────────────────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌───────────────────────────────────────────────┐ │ │ │ +│ │ │ │ EnsembleScreenRenderer │ │ │ │ +│ │ │ │ - Renders Ensemble YAML UI definitions │ │ │ │ +│ │ │ │ - Widgets wrapped with TVFocusWidget │ │ │ │ +│ │ │ └───────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Focus Grid Alignment + +``` +flutter_pca Tab Bar (Row 0): + [Home:0] [Live:1] [Movies:2] [Series:3] [Watchlist:4] [Sports:5] [Apps:6] + +Ensemble Content (Row 1+, aligned with Sports at order 5): + [Match1:5] [Match2:6] [Match3:7] [Match4:8] + +↑ UP from Ensemble → Sports tab +↓ DOWN from Sports tab → Ensemble content +``` + +--- + +## 3. tvOptions Property Reference + +### Complete tvOptions Structure + +```yaml +tvOptions: + # Navigation Properties (Required for TV focus) + row: 0 # Vertical position in focus grid + order: 0 # Horizontal position within row + isRowEntryPoint: true # Preferred entry point when navigating into row + + # Focus Indicator Styling (Optional) + focusBorderRadius: 16 # Border radius for focus indicator (pixels) + focusColor: 0xFF00AAFF # Focus indicator border color + focusBorderWidth: 2 # Focus indicator border width (pixels) + + # Focused State Styling (Optional - widget appearance when focused) + backgroundColor: 0xFF1A1A1A # Background color when focused + backgroundGradient: # Background gradient when focused + colors: [0xFF1A1A1A, 0xFF2A2A2A] + borderColor: 0xFFFFFFFF # Border color when focused + borderWidth: 2 # Border width when focused + borderRadius: 8 # Border radius when focused + boxShadow: # Box shadow when focused + color: 0x40000000 + offset: [0, 4] + blur: 8 + opacity: 1.0 # Opacity when focused (0.0 to 1.0) + elevation: 4 # Elevation when focused (0 to 24) + scale: 1.05 # Scale factor when focused (e.g., 1.05 = 5% larger) + padding: 12 # Padding when focused + margin: 8 # Margin when focused + + # Scroll Behavior (Optional - for horizontal lists) + fixedFocusScroll: true # Enable Netflix-style scrolling + fixedFocusOffset: 48 # Offset from left edge (pixels) + verticalScrollPadding: 100 # Extra padding when scrolling vertically + scrollAnimationDuration: 200 # Scroll animation duration (ms) + scrollAnimationCurve: easeOut # Animation curve + horizontalScrollPadding: 16 # Horizontal padding for visibility checks + + # Horizontal Navigation Control (for carousels) + delegateHorizontalNavigation: true # Delegate LEFT/RIGHT to parent FocusScope + lockHorizontalNavigation: true # Block LEFT/RIGHT at row boundaries + + # Carousel-Specific (on Carousel widget) + interceptHorizontalNav: true # Smart edge detection for LEFT/RIGHT + pauseAutoplayOnFocus: true # Pause autoplay when focused + restoreFocusOnPageChange: true # Restore focus after slide change + + # ListView Scrollbar (on ListView widget) + scrollbarOptions: + position: right # 'left' or 'right' + color: 0xFF666666 # Color when not focused + focusedColor: 0xFFFFFFFF # Color when focused + width: 3 # Width when not focused (pixels) + focusedWidth: 6 # Width when focused (pixels) + radius: 4 # Border radius of scrollbar + thumbHeight: 40 # Fixed thumb height (pixels) +``` + +### Property Details + +#### Navigation Properties + +| Property | Type | Default | Description | +| -------------------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **row** | `double` | `null` | **Required**. Vertical position in the focus grid (0, 1, 2, ...). Items with the same row navigate horizontally with LEFT/RIGHT. | +| **order** | `double` | `0` | Horizontal position within the row (0, 1, 2, ...). Lower values = more left. Must be unique within a row. | +| **isRowEntryPoint** | `bool` | `false` | Marks this item as the preferred entry point when navigating INTO this row from another row. | +| **delegateHorizontalNavigation** | `bool` | `false` | When `true`, LEFT/RIGHT events are delegated to parent FocusScope. Use for items inside carousels. | +| **lockHorizontalNavigation** | `bool` | `false` | When `true`, prevents horizontal navigation from escaping row at boundaries. | + +#### Focus Indicator Styling + +These properties control the **focus indicator border** that appears around focused widgets. + +| Property | Type | Default | Description | +| -------------------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **focusColor** | `Color` | theme default | Custom color for focus indicator border. Accepts hex (0xFF00AAFF). | +| **focusBorderWidth** | `double` | `3.0` | Custom border width for focus indicator (pixels). | +| **focusBorderRadius** | `double` | theme default | Custom border radius for focus indicator. Use `22` for 44px circular buttons, `100` for pills. | + +#### Focused State Styling + +These properties change the **widget's appearance** when focused (not the focus indicator, but the widget itself). When unfocused, the widget uses its normal styles. + +| Property | Type | Default | Description | +| -------------------------------- | ----------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **backgroundColor** | `Color` | `null` | Background color when focused. Overrides widget's normal backgroundColor. | +| **backgroundGradient** | `Gradient` | `null` | Background gradient when focused. Overrides widget's normal backgroundGradient. | +| **borderColor** | `Color` | `null` | Border color when focused (widget's own border, not the focus indicator). | +| **borderWidth** | `int` | `null` | Border width when focused (widget's own border). | +| **borderRadius** | `BorderRadius` | `null` | Border radius when focused. | +| **boxShadow** | `BoxShadow` | `null` | Box shadow when focused. Accepts shadow composite (color, offset, blur, spread). | +| **opacity** | `double` | `null` | Opacity when focused (0.0 to 1.0). Use to fade/brighten widgets. | +| **elevation** | `int` | `null` | Material elevation when focused (0 to 24). Creates shadow depth. | +| **scale** | `double` | `null` | Scale factor when focused (e.g., 1.05 = 5% larger, 0.95 = 5% smaller). | +| **padding** | `EdgeInsets` | `null` | Padding when focused. Overrides widget's normal padding. | +| **margin** | `EdgeInsets` | `null` | Margin when focused. Overrides widget's normal margin. | + +#### Scroll Behavior + +| Property | Type | Default | Description | +| -------------------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **fixedFocusScroll** | `bool` | `false` | Netflix-style scrolling where focused item stays at fixed position while content scrolls. | +| **fixedFocusOffset** | `double` | `48.0` | Offset from left edge where focused item stays during fixed focus scrolling. | +| **verticalScrollPadding** | `double` | `0.0` | Extra padding when auto-scrolling vertically to keep focused item visible. | +| **horizontalScrollPadding** | `double` | `16.0` | Horizontal padding for visibility checks during scrolling. | +| **scrollAnimationDuration** | `int` | `200` | Duration of scroll animations in milliseconds. | +| **scrollAnimationCurve** | `String` | `easeOut` | Animation curve: easeIn, easeOut, easeInOut, linear, decelerate, ease. | + +#### Carousel-Specific Properties + +| Property | Type | Default | Description | +| -------------------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **interceptHorizontalNav** | `bool` | `false` | Smart edge detection for LEFT/RIGHT navigation in carousels. | +| **pauseAutoplayOnFocus** | `bool` | `false` | Pause carousel autoplay when any element within has focus. | +| **restoreFocusOnPageChange** | `bool` | `false` | Restore focus after carousel slide change. | + +#### ListView Scrollbar Properties + +These properties are set under `tvOptions.scrollbarOptions` on ListView widgets. + +| Property | Type | Default | Description | +| ------------------ | -------- | ------------- | ------------------------------------------------------------------------------------------------ | +| **position** | `String` | `'right'` | Scrollbar position: `'left'` or `'right'`. | +| **color** | `Color` | `0xFF666666` | Scrollbar color when not focused. | +| **focusedColor** | `Color` | `0xFFFFFFFF` | Scrollbar color when focused. | +| **width** | `int` | `3` | Scrollbar width in pixels when not focused. | +| **focusedWidth** | `int` | `6` | Scrollbar width in pixels when focused (wider for visibility). | +| **radius** | `int` | `4` | Border radius of scrollbar corners. | +| **thumbHeight** | `int` | `40` | Fixed height of scrollbar thumb in pixels. | + +### Where to Apply tvOptions + +Apply `tvOptions` in the `styles` section of focusable widgets: + +```yaml +Column: + styles: + tvOptions: + row: 0 + order: 0 + onTap: + navigateBack: +``` + +**Important**: Only widgets with `onTap` handlers OR form widgets (Switch, TextInput, etc.) can receive focus. + +--- + +## 4. YAML Patterns & Examples + +### Pattern 1: Simple List (One Focusable per Item) + +```yaml +item-template: + data: ${items} + name: item + indexId: itemIndex + template: + Column: + styles: + tvOptions: + row: 1 + order: ${itemIndex} + isRowEntryPoint: ${itemIndex == 0} + onTap: ... +``` + +### Pattern 2: Multiple Focusables per Item (CRITICAL) + +When each list item has MULTIPLE focusable elements, multiply the index: + +```yaml +# CORRECT - No conflicts +Switch: + styles: + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2} # Item 0: order 0, Item 1: order 2 +Delete: + styles: + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2 + 1} # Item 0: order 1, Item 1: order 3 +``` + +**Formula**: For N focusable elements per item: + +- Element 0: `order: ${index * N}` +- Element 1: `order: ${index * N + 1}` +- Element 2: `order: ${index * N + 2}` + +### Pattern 3: Netflix-Style Horizontal List + +```yaml +Row: + styles: + scrollable: true + gap: 8 + item-template: + data: ${mediaItems} + name: item + indexId: mediaIndex + template: + MediaCard: + inputs: + tvOptions: + row: 5 + order: ${mediaIndex} + isRowEntryPoint: ${mediaIndex == 0} + fixedFocusScroll: true + fixedFocusOffset: 48 + lockHorizontalNavigation: true +``` + +### Pattern 4: Grid Layout + +```yaml +item-template: + data: ${items} + indexId: itemIndex + template: + Card: + styles: + tvOptions: + row: ${1 + Math.floor(itemIndex / 4)} # Row changes every 4 items + order: ${itemIndex % 4} # 0, 1, 2, 3, 0, 1, 2, 3... +``` + +### Pattern 5: Passing tvOptions to Custom Widgets + +**Widget Definition:** + +```yaml +Widget: + inputs: + - item + - tvOptions + body: + Column: + styles: + tvOptions: ${tvOptions} + onTap: ... +``` + +**Widget Usage:** + +```yaml +item-template: + data: ${items} + indexId: idx + template: + MyWidget: + inputs: + item: ${item} + tvOptions: + row: ${1 + idx} + order: 0 +``` + +--- + +## 5. Carousel TV Implementation + +### Carousel-Level tvOptions + +```yaml +Carousel: + layout: single + autoplay: true + autoplayInterval: 5000 + styles: + tvOptions: + row: 1 + interceptHorizontalNav: true # Smart edge detection + pauseAutoplayOnFocus: true # Pause when user navigates + restoreFocusOnPageChange: true # Restore focus after slide change +``` + +### Item-Level: delegateHorizontalNavigation + +For focusable elements inside carousel slides: + +```yaml +# Inside carousel slide +Button: + label: "Watch Now" + styles: + tvOptions: + row: 1 + order: 0 + delegateHorizontalNavigation: true # LEFT/RIGHT switch slides +``` + +### How Carousel Navigation Works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Carousel (interceptHorizontalNav: true) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Slide 1 │ │ +│ │ ┌──────────────────────┐ │ │ +│ │ │ [Button] │ ← delegateHorizontalNavigation: true │ │ +│ │ │ LEFT/RIGHT delegated │ │ │ +│ │ └──────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ When user presses LEFT/RIGHT: │ +│ 1. Button delegates to parent FocusScope │ +│ 2. Carousel intercepts the event │ +│ 3. Carousel switches to previous/next slide │ +│ 4. Focus is restored to new slide's button │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Complete Hero Carousel Example + +```yaml +HeroCarousel: + body: + Carousel: + layout: single + height: 400 + autoplay: true + autoplayInterval: 5 + enableLoop: true + indicatorType: circle + styles: + tvOptions: + pauseAutoplayOnFocus: true + interceptHorizontalNav: true + restoreFocusOnPageChange: true + item-template: + data: ${competitions} + name: competition + indexId: slideIndex + template: + HeroSlide: + inputs: + competition: ${competition} + slideIndex: ${slideIndex} + +HeroSlide: + inputs: + - competition + - slideIndex + body: + Stack: + styles: + width: ${device.width - 96} + height: 400 + margin: 0 48 + borderRadius: 12 + clipContent: true + children: + - Image: + source: ${competition.heroImage} + styles: + width: ${device.width - 96} + height: 400 + fit: cover + + - Column: + styles: + width: ${device.width - 96} + height: 400 + mainAxis: end + crossAxis: start + padding: 48 48 80 48 + gap: 12 + backgroundGradient: + colors: + - 0x00000000 + - 0x60000000 + - 0xcc000000 + - 0xff000000 + stops: + - 0.0 + - 0.40 + - 0.65 + - 0.85 + start: topCenter + end: bottomCenter + children: + - Text: + text: ${competition.title} + styles: + textStyle: + fontSize: 36 + fontWeight: bold + + - Text: + text: ${competition.description} + + - Button: + label: "View Competition" + styles: + margin: 8 0 0 0 + tvOptions: + row: 0 + order: ${slideIndex} + verticalScrollPadding: 400 + delegateHorizontalNavigation: true + onTap: + navigateScreen: + name: CompetitionDetails +``` + +### Key Difference: delegateHorizontalNavigation vs lockHorizontalNavigation + +| Property | Behavior | Use Case | +| ------------------------------ | ------------------------------------------------ | --------------------------- | +| `delegateHorizontalNavigation` | Event bubbles up to parent (carousel handles it) | Items inside carousels | +| `lockHorizontalNavigation` | Event is blocked/consumed (nothing happens) | Standalone horizontal lanes | + +--- + +## 6. ListView Scrollbar + +### Overview + +ListView on TV supports a focusable scrollbar that allows users to scroll content using D-pad navigation. The scrollbar: + +- Appears on the left or right edge of the ListView +- Becomes focusable via D-pad navigation (RIGHT to right-side scrollbar, LEFT to left-side scrollbar) +- Changes visual state when focused (color, width) +- Scrolls content with UP/DOWN keys when focused +- Works correctly with multi-column content layouts + +### Architecture: Edge Handlers + +The scrollbar uses **edge handlers** integrated into the TVFocusWidget grid system: + +``` +┌────────────────────────────────────────────────────────────────┐ +│ ListView │ +│ ┌──────────────────────────────────────────────────┐ ┌────┐ │ +│ │ TVFocusScope │ │ │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ S │ │ +│ │ │ Item (row=1, order=0) ─RIGHT→ Item (order=1) │→│ C │ │ +│ │ │ Item (row=2, order=0) ─RIGHT→ Item (order=1) │→│ R │ │ +│ │ │ Item (row=3, order=0) ─RIGHT→ Item (order=1) │→│ O │ │ +│ │ └────────────────────────────────────────────┘ │ │ L │ │ +│ │ ↑ │ │ L │ │ +│ │ onRightEdge: scrollbar.requestFocus() │ │ │ │ +│ └──────────────────────────────────────────────────┘ └────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +**How it works:** +1. User navigates through grid items using D-pad +2. When at the rightmost item (e.g., order=1), pressing RIGHT triggers `onRightEdge` +3. `onRightEdge` callback requests focus on the scrollbar +4. Scrollbar receives focus and handles UP/DOWN for scrolling +5. LEFT returns focus back to content + +### Basic Usage + +```yaml +ListView: + styles: + expanded: true + tvOptions: + scrollbarOptions: + position: right # 'left' or 'right' + color: 0xFF666666 # Grey when not focused + focusedColor: 0xFFFFFFFF # White when focused + width: 3 # Thin when not focused + focusedWidth: 6 # Wider when focused + radius: 4 # Corner radius + thumbHeight: 40 # Thumb size + item-template: + data: ${items} + name: item + indexId: idx + template: + Column: + styles: + tvOptions: + row: ${1 + idx} + order: 0 + isRowEntryPoint: ${idx == 0} + onTap: + showToast: + message: Tapped ${item.title} +``` + +### Multi-Column Content + +The scrollbar correctly handles multi-column layouts. Users must navigate through all columns before reaching the scrollbar: + +```yaml +ListView: + styles: + tvOptions: + scrollbarOptions: + position: right + item-template: + data: ${items} + indexId: idx + template: + Row: + children: + # Left column (order 0) + - Column: + styles: + tvOptions: + row: ${1 + idx} + order: 0 + onTap: ... + + # Right column (order 1) + - Column: + styles: + tvOptions: + row: ${1 + idx} + order: 1 + onTap: ... +``` + +**Navigation flow:** +``` +Item (order 0) ─RIGHT→ Item (order 1) ─RIGHT→ Scrollbar + │ +Item (order 0) ←LEFT─ Item (order 1) ←LEFT─ ─┘ +``` + +### Left-Positioned Scrollbar + +For left-side scrollbar, use `position: left`: + +```yaml +ListView: + styles: + tvOptions: + scrollbarOptions: + position: left + color: 0xFF666666 + focusedColor: 0xFFFFFFFF + item-template: + data: ${items} + indexId: idx + template: + Column: + styles: + tvOptions: + row: ${1 + idx} + order: 0 + onTap: ... +``` + +**Navigation flow (left position):** +``` +Scrollbar ←LEFT─ Item (order 0) + │ + └─RIGHT→ Item (order 0) +``` + +### Scrollbar Styling Options + +| Property | Default | Description | +| ---------------- | ------------- | ---------------------------------------- | +| `position` | `'right'` | `'left'` or `'right'` side of ListView | +| `color` | `0xFF666666` | Track and thumb color when not focused | +| `focusedColor` | `0xFFFFFFFF` | Track and thumb color when focused | +| `width` | `3` | Width in pixels when not focused | +| `focusedWidth` | `6` | Width in pixels when focused (wider) | +| `radius` | `4` | Corner radius of scrollbar | +| `thumbHeight` | `40` | Fixed height of scrollbar thumb | + +### Complete Example: Settings List with Scrollbar + +```yaml +View: + title: Settings + + onLoad: + executeCode: + body: | + ensemble.storage.settings = [ + {name: 'Notifications', enabled: true}, + {name: 'Dark Mode', enabled: false}, + {name: 'Auto-play', enabled: true}, + # ... more settings + ]; + + body: + Column: + children: + # Header + - Row: + styles: + tvOptions: + row: 0 + order: 0 + onTap: + navigateBack: + children: + - Icon: + name: arrow_back + - Text: + text: Settings + + # Settings list with scrollbar + - ListView: + styles: + expanded: true + tvOptions: + scrollbarOptions: + position: right + color: 0xFF444444 + focusedColor: 0xFF00AAFF + width: 4 + focusedWidth: 8 + item-template: + data: ${ensemble.storage.settings} + name: setting + indexId: idx + template: + Row: + styles: + padding: 16 + mainAxis: spaceBetween + children: + - Text: + text: ${setting.name} + - Switch: + value: ${setting.enabled} + styles: + tvOptions: + row: ${1 + idx} + order: 0 + isRowEntryPoint: ${idx == 0} + onChange: | + ensemble.storage.settings[${idx}].enabled = event.value; +``` + +### Key Points + +1. **Only on TV**: Scrollbar only renders on TV devices (not mobile/web) +2. **ScrollController Required**: ListView must have a scroll controller (automatic for most cases) +3. **Grid Navigation**: Works with TVFocusWidget's 2D grid system via edge handlers +4. **Multi-Column Safe**: Correctly navigates through all columns before scrollbar +5. **Bidirectional**: LEFT from right scrollbar (or RIGHT from left scrollbar) returns to content +6. **Visual Feedback**: Scrollbar changes color/width when focused + +--- + +## 7. Host App Integration (flutter_pca) + +### TVFocusProvider Interface + +```dart +abstract class TVFocusProvider { + double get rowOffset; // Ensemble content starts at this row + double get orderOffset; // Ensemble content starts at this order + + Color? get focusColor; + double? get focusBorderWidth; + double? get focusBorderRadius; + + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + KeyEventResult Function(FocusNode node)? onBackPressed, + bool disableHostScroll = true, + }); + + void dispose(); +} +``` + +### flutter_pca Implementation + +```dart +class PageFocusProvider implements TVFocusProvider { + @override + double get rowOffset => 1.0; // Below tab bar + + @override + double get orderOffset => 5.0; // Aligned with Sports tab + + @override + Color? get focusColor => AppThemeManager.currentTheme.snowGrey; + + @override + double? get focusBorderWidth => 1.5; + + @override + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + KeyEventResult Function(FocusNode node)? onBackPressed, + bool disableHostScroll = true, + }) { + return PageFocusWidget( + focusOrder: PageFocusOrder.withOptions( + row, + order: order, + isRowEntryPoint: isRowEntryPoint, + lockHorizontalNavigation: lockHorizontalNavigation, + delegateHorizontalNavigation: delegateHorizontalNavigation, + disableHostScroll: disableHostScroll, + ), + goBackButtonHandled: onBackPressed, + disableHostScroll: disableHostScroll, + child: child, + ); + } +} +``` + +### Integration with EnsembleApp + +```dart +// In Sports tab +EnsembleWrapper( + tvFocusProvider: PageFocusProvider(), + child: EnsembleScreen(payload: payload), +) + +// Internally wraps with scope: +if (widget.tvFocusProvider != null) { + app = TVFocusProviderScope( + provider: widget.tvFocusProvider!, + child: app, + ); +} +``` + +### Files Changed for flutter_pca Integration + +| File | Description | +| ---------------------------------------------------------- | ------------------------------------------------- | +| `lib/screens/widgets/custom/page_focus_provider.dart` | Bridge between Ensemble and flutter_pca | +| `lib/screens/widgets/custom/pca_button.dart` | PageFocusWidget with delegateHorizontalNavigation | +| `lib/screens/ensemble/ensemble_wrapper.dart` | TVFocusProvider integration | +| `lib/screens/ensemble/platform.tv/ensemble.tv.screen.dart` | TV screen with focus scope | + +--- + +## 8. Focus Styling + +### Focus Indicator Styling Priority Chain + +The focus indicator border color, width, and radius follow this priority order: + +``` +1. Per-Widget Override (styles.tvOptions.focusColor/focusBorderWidth/focusBorderRadius) + ↓ (if not set) +2. Theme Configuration (theme.yaml - Common.Tokens.TV.*) + ↓ (if not set) +3. TVFocusProvider (host app integration, e.g., flutter_pca) + ↓ (if not set) +4. Widget's Normal Styles (styles.borderColor/borderWidth/borderRadius) + ↓ (if not set) +5. Ensemble Defaults: + - Focus Color: App's primary color (Theme.of(context).colorScheme.primary) + - Border Width: 3.0px + - Border Radius: 8.0px + - Animation Duration: 150ms +``` + +**Source:** [box_wrapper.dart:799-850](../modules/ensemble/lib/widget/helpers/box_wrapper.dart) + +### Focused State Styling Priority + +Focused state properties (backgroundColor, scale, elevation, etc.) have a simpler chain: + +``` +1. Per-Widget Override (styles.tvOptions.backgroundColor/scale/elevation/...) + ↓ (if not set) +2. Widget's Normal Styles (styles.backgroundColor/padding/margin/...) + ↓ (unfocused state) +``` + +**Note:** When focused, tvOptions properties override the widget's normal styles. When unfocused, the widget uses its normal styles from the `styles` section. + +### Theme Configuration (theme.yaml) + +```yaml +Common: + Tokens: + TV: + focusColor: 0xFF00AAFF + focusBorderWidth: 3 + focusBorderRadius: 8 + focusAnimationDuration: 150 +``` + +### Per-Widget Override (Focus Indicator) + +```yaml +Button: + label: "Special Button" + styles: + tvOptions: + row: 1 + order: 0 + focusColor: 0xFFFF0000 # Red focus border + focusBorderWidth: 4 + focusBorderRadius: 24 +``` + +### Focused State Styling Examples + +#### Example 1: Scale and Brighten on Focus + +```yaml +MediaCard: + styles: + backgroundColor: 0xFF1A1A1A + tvOptions: + row: 2 + order: ${index} + # Normal widget styles above + # Focused state styles below + scale: 1.05 # Grow 5% when focused + backgroundColor: 0xFF2A2A2A # Lighter background + onTap: + navigateScreen: + name: MediaDetails +``` + +#### Example 2: Add Shadow and Border on Focus + +```yaml +Button: + label: "Play" + styles: + backgroundColor: 0xFF2196F3 + borderRadius: 8 + tvOptions: + row: 1 + order: 0 + # Focused state + elevation: 8 # Add shadow depth + borderColor: 0xFFFFFFFF # White border + borderWidth: 2 + scale: 1.02 # Slight zoom +``` + +#### Example 3: Change Background Gradient on Focus + +```yaml +Column: + styles: + backgroundGradient: + colors: [0xFF1A1A1A, 0xFF0A0A0A] + tvOptions: + row: 3 + order: ${idx} + # Different gradient when focused + backgroundGradient: + colors: [0xFF2196F3, 0xFF1976D2] + start: topLeft + end: bottomRight + onTap: ... +``` + +#### Example 4: Padding/Margin Animation on Focus + +```yaml +Card: + styles: + padding: 12 + margin: 8 + tvOptions: + row: 4 + order: ${idx} + # Adjust spacing when focused + padding: 16 # More padding + margin: 4 # Less margin (appears to grow) + onTap: ... +``` + +#### Example 5: Opacity Fade on Focus + +```yaml +TabButton: + styles: + opacity: 0.5 # Dim when not focused + tvOptions: + row: 0 + order: ${idx} + opacity: 1.0 # Full brightness when focused + onTap: + executeCode: + body: | + switchTab(${idx}); +``` + +#### Example 6: Complete Media Card with All Effects + +```yaml +MediaCard: + styles: + width: 200 + height: 300 + borderRadius: 12 + backgroundColor: 0xFF1A1A1A + tvOptions: + row: ${rowIndex} + order: ${itemIndex} + isRowEntryPoint: ${itemIndex == 0} + # Focus indicator + focusColor: 0xFFFFFFFF + focusBorderWidth: 3 + focusBorderRadius: 12 + # Focused state (widget transformation) + scale: 1.08 + backgroundColor: 0xFF2A2A2A + elevation: 12 + boxShadow: + color: 0x60000000 + offset: [0, 8] + blur: 16 + spread: 2 + onTap: + navigateScreen: + name: MediaDetails + children: + - Image: + source: ${media.poster} + styles: + width: 200 + height: 300 + fit: cover +``` + +### Focus Indicator vs Focused State + +**Focus Indicator** (focusColor, focusBorderWidth, focusBorderRadius): +- The **border** that appears around the focused widget +- Controlled by Ensemble's TV focus system +- Always a simple border overlay + +**Focused State** (backgroundColor, scale, elevation, etc.): +- Changes to the **widget itself** when focused +- Animated transitions (150ms default) +- Stacks with focus indicator for rich effects + +**Combined Example:** +```yaml +Button: + label: "Watch Now" + styles: + backgroundColor: 0xFF2196F3 + borderRadius: 8 + tvOptions: + row: 1 + order: 0 + # Focus indicator (white border around button) + focusColor: 0xFFFFFFFF + focusBorderWidth: 3 + focusBorderRadius: 10 + # Focused state (button itself changes) + scale: 1.05 + backgroundColor: 0xFF1E88E5 + elevation: 8 +``` + +Result when focused: +1. Button grows 5% larger (scale: 1.05) +2. Button background changes to darker blue +3. Button gets 8px elevation shadow +4. **Then** white 3px focus border appears around it + +--- + +## 9. Common UI Patterns + +### Back Button (44x44 Circular) + +```yaml +- Column: + styles: + width: 44 + height: 44 + mainAxis: center + crossAxis: center + tvOptions: + row: 0 + order: 0 + focusBorderRadius: 22 + onTap: + navigateBack: + children: + - Image: + source: ${env.assets}${env.back_icon_png} + styles: + width: 44 + height: 44 + placeholderColor: transparent +``` + +### "View All" Link with Arrow + +```yaml +Row: + styles: + mainAxis: spaceBetween + children: + - Text: + text: Section Title + - Row: + styles: + gap: 8 + crossAxis: center + padding: 4 8 + tvOptions: + row: 10 + order: 0 + focusBorderRadius: 16 + onTap: + navigateScreen: + name: TargetScreen + children: + - Text: + text: View all + className: linkTextSmall + - Icon: + name: arrow_forward + color: 0xff00aaff + size: 16 +``` + +### Switch in List + +```yaml +- Switch: + styles: + flexMode: none + maxWidth: 60 + width: 60 + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2} + value: ${item.notifications} + onChange: ... +``` + +### Dialog Focus Order + +```yaml +DialogWidget: + body: + Column: + children: + # Close button (row 0) + - FlexRow: + children: + - Text: + text: Title + styles: + flexMode: expanded + - Column: + styles: + flexMode: none + tvOptions: + row: 0 + order: 0 + onTap: + closeAllDialogs: + children: + - Icon: + name: close + + # Content items (row 1+) + - Column: + item-template: + data: ${items} + indexId: idx + template: + ItemWidget: + inputs: + tvOptions: + row: ${1 + idx} + order: 0 + + # Save button (high row number) + - Button: + label: Save + styles: + tvOptions: + row: 50 + order: 0 +``` + +--- + +## 10. Pitfalls to Avoid + +### Order Conflicts in Lists + +```yaml +# WRONG - Creates conflicts! +Switch: order: ${index} # 0, 1, 2 +Delete: order: ${index + 1} # 1, 2, 3 <- Item 1 Switch (1) = Item 0 Delete (1) + +# CORRECT - No conflicts +Switch: order: ${index * 2} # 0, 2, 4 +Delete: order: ${index * 2 + 1} # 1, 3, 5 +``` + +### tvOptions on Wrong Element + +```yaml +# WRONG - Switch won't be focusable! +Column: + styles: + tvOptions: + row: 0 + children: + - Switch: + value: true + +# CORRECT - tvOptions on the Switch itself +Column: + children: + - Switch: + styles: + tvOptions: + row: 0 + value: true +``` + +### Missing flexMode in FlexRow + +```yaml +# WRONG - Column may expand unexpectedly +FlexRow: + children: + - Text: + styles: + flexMode: expanded + - Column: + styles: + width: 40 + onTap: ... + +# CORRECT - Explicitly set flexMode: none +FlexRow: + children: + - Text: + styles: + flexMode: expanded + - Column: + styles: + flexMode: none + width: 40 + onTap: ... +``` + +### Size Mismatch (Gap Between Border and Content) + +```yaml +# WRONG - 4px gap on each side! +Column: + styles: + width: 48 + height: 48 + tvOptions: + focusBorderRadius: 24 + children: + - Image: + styles: + width: 40 + height: 40 + +# CORRECT - Same size, no gap +Column: + styles: + width: 44 + height: 44 + tvOptions: + focusBorderRadius: 22 + children: + - Image: + styles: + width: 44 + height: 44 +``` + +--- + +## 11. Testing Checklist + +### Navigation + +- [ ] All focusable elements have unique (row, order) pairs +- [ ] D-pad UP/DOWN moves between rows correctly +- [ ] D-pad LEFT/RIGHT moves within rows correctly +- [ ] First item in each row has `isRowEntryPoint: true` +- [ ] Back button is always row 0, order 0 +- [ ] Dialog focus is trapped within dialog + +### Focus Visual + +- [ ] Focus border has appropriate radius matching widget shape +- [ ] No visual gap between focus border and widget content +- [ ] Focus color is visible against background + +### Scrolling (fixedFocusScroll) + +- [ ] Content scrolls smoothly while focus stays fixed +- [ ] Focus moves correctly at list boundaries +- [ ] `fixedFocusOffset` positions the focused item appropriately +- [ ] Scroll position resets when re-entering the row + +### Carousel Navigation + +- [ ] LEFT/RIGHT on carousel items switches slides +- [ ] Focus is restored to new slide's button after slide change +- [ ] Autoplay pauses when carousel item is focused +- [ ] Autoplay resumes when focus leaves carousel +- [ ] UP/DOWN exits carousel to adjacent rows correctly +- [ ] `lockHorizontalNavigation` blocks escape at lane boundaries + +### ListView Scrollbar + +- [ ] Scrollbar appears on correct side (left/right based on position) +- [ ] Scrollbar only shows on TV (not mobile/web) +- [ ] RIGHT from rightmost item goes to scrollbar (right position) +- [ ] LEFT from leftmost item goes to scrollbar (left position) +- [ ] Multi-column navigation works before reaching scrollbar +- [ ] Scrollbar visual changes when focused (color, width) +- [ ] UP/DOWN on scrollbar scrolls content smoothly +- [ ] LEFT from right scrollbar returns to content +- [ ] RIGHT from left scrollbar returns to content +- [ ] Scrollbar thumb position syncs with scroll position + +--- + +## 12. Troubleshooting + +### Focus not working on Ensemble widgets + +1. Verify `row` is set in `tvOptions` +2. Check if `TVFocusProviderScope` is wrapping content +3. Ensure widget has `onTap` handler or is a form widget +4. Verify `rowOffset` and `orderOffset` values in provider + +### Navigation skipping items + +1. Check for duplicate row/order values +2. Verify items are within same `FocusTraversalGroup` +3. Check `lockHorizontalNavigation` settings + +### Scroll not following focus + +1. Enable `fixedFocusScroll: true` +2. Set appropriate `fixedFocusOffset` +3. Check if host's `handlesHorizontalScroll` is interfering + +### Carousel slides not switching + +1. Verify `delegateHorizontalNavigation: true` on carousel items +2. Check `interceptHorizontalNav: true` on carousel +3. Ensure carousel's FocusScope is receiving bubbled events + +### Navigation conflicts with native content + +1. Ensure `EnsembleWrapper` is scoped correctly (inside tab, not top-level) +2. Check row/order alignment with host app's focus grid +3. Verify cross-boundary navigation is enabled + +### Scrollbar not receiving focus + +1. Verify `scrollbarOptions` is set under `tvOptions` (not directly in `styles`) +2. Check position value is `'left'` or `'right'` (string, not unquoted) +3. For multi-column: ensure you're at the rightmost/leftmost item before pressing the edge key +4. Check logs for `[TVFocusWidget] At right edge - calling onRightEdge handler` +5. Verify ListView has a scroll controller (automatic for most cases) + +### Scrollbar not scrolling content + +1. Verify ListView content exceeds viewport (scrollbar needs scrollable content) +2. Check if `scrollController.hasClients` is true +3. Look for errors in logs related to scroll position + +### Scrollbar appearing on wrong side + +1. Verify `position` property value: `'left'` or `'right'` +2. Check for typos in position value +3. Ensure quotes around position value in YAML + +--- + +## Row Number Guidelines + +| Screen Section | Recommended Row Range | +| ------------------------------- | --------------------- | +| Header (back button, actions) | 0 | +| Hero Carousel | 0 (carousel items) | +| Info/help buttons | 1 | +| Favorites row | 1 | +| Main content lists | 2-4 | +| Section headers with "View all" | 5, 7 | +| Section content rows | 6, 8 | +| Footer buttons | 50+ | + +**Note**: Leave gaps between sections to allow for future additions. + +--- + +## Related Documentation + +- [TV_FOCUS_NAVIGATION_RULES.md](TV_FOCUS_NAVIGATION_RULES.md) - Quick reference patterns and rules for TV navigation YAML +- [TV_IMPLEMENTATION_HISTORY.md](TV_IMPLEMENTATION_HISTORY.md) - Technical implementation details and commit history +- [ENSEMBLE_FRAMEWORK_REFERENCE.md](ENSEMBLE_FRAMEWORK_REFERENCE.md) - General Ensemble framework reference + +--- + +## Appendix: Quick Reference Card + +### Minimum TV-Enabled Widget + +```yaml +Button: + label: "Click" + styles: + tvOptions: + row: 1 + order: 0 + onTap: ... +``` + +### Horizontal List Item + +```yaml +tvOptions: + row: 2 + order: ${index} + isRowEntryPoint: ${index == 0} + lockHorizontalNavigation: true + fixedFocusScroll: true + fixedFocusOffset: 48 +``` + +### Carousel Item Button + +```yaml +tvOptions: + row: 0 + order: ${slideIndex} + delegateHorizontalNavigation: true + verticalScrollPadding: 400 +``` + +### Carousel Container + +```yaml +tvOptions: + pauseAutoplayOnFocus: true + interceptHorizontalNav: true + restoreFocusOnPageChange: true +``` diff --git a/modules/adobe_analytics/pubspec.yaml b/modules/adobe_analytics/pubspec.yaml index bf2b81570..890aae2a6 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.44 + ref: ensemble-v1.2.38-beta.7 path: modules/ensemble flutter_aepcore: ^5.0.0 diff --git a/modules/auth/pubspec.yaml b/modules/auth/pubspec.yaml index 14644b513..681f9bf08 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.44 + ref: ensemble-v1.2.38-beta.7 path: modules/ensemble ensemble_ts_interpreter: ^1.0.7 diff --git a/modules/bracket/pubspec.yaml b/modules/bracket/pubspec.yaml index d25e6c09a..38c1e0934 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.44 + ref: ensemble-v1.2.38-beta.7 path: modules/ensemble dev_dependencies: diff --git a/modules/camera/pubspec.yaml b/modules/camera/pubspec.yaml index a00c46510..253fcb282 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.44 + ref: ensemble-v1.2.38-beta.7 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.44 + ref: ensemble-v1.2.38-beta.7 path: modules/qr_scanner collection: ^1.17.1 diff --git a/modules/chat/pubspec.yaml b/modules/chat/pubspec.yaml index 15b1cf97b..1cf56693f 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.44 + ref: ensemble-v1.2.38-beta.7 path: modules/ensemble ensemble_ts_interpreter: ^1.0.7 diff --git a/modules/connect/pubspec.yaml b/modules/connect/pubspec.yaml index 47fda5461..acc935e62 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.44 + ref: ensemble-v1.2.38-beta.7 path: modules/ensemble plaid_flutter: ^3.1.2 diff --git a/modules/contacts/pubspec.yaml b/modules/contacts/pubspec.yaml index 1efc21316..5b5d34205 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.44 + ref: ensemble-v1.2.38-beta.7 path: modules/ensemble flutter_contacts: ^1.1.7+1 diff --git a/modules/deeplink/pubspec.yaml b/modules/deeplink/pubspec.yaml index d5ab1199b..7e816c49a 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.44 + ref: ensemble-v1.2.38-beta.7 path: modules/ensemble flutter_branch_sdk: ^7.0.1 diff --git a/modules/ensemble/CHANGELOG.md b/modules/ensemble/CHANGELOG.md index 3fbf0b1b3..d15ef6e65 100644 --- a/modules/ensemble/CHANGELOG.md +++ b/modules/ensemble/CHANGELOG.md @@ -1,3 +1,34 @@ +## 1.2.38-beta.7 + + - **REFACTOR**(tabbar): remove useIndexedTab setter duplication from TabBarController. ([faafd299](https://github.com/ensembleUI/ensemble/commit/faafd2996c3f0a9b9609acfba4e3286165e2ca80)) + - **REFACTOR**(cdn): improve secret management and artifact handling in CdnDefinitionProvider. ([a2c87792](https://github.com/ensembleUI/ensemble/commit/a2c877925a4d2ac489aab8c5a89f3e66e4dd5a98)) + - **FIX**(upload): scope cancelAll to upload Workmanager tags only. ([4d763ac4](https://github.com/ensembleUI/ensemble/commit/4d763ac4c883363a96a6a5b8b3c264bf8fb3f0b3)) + - **FIX**(upload): schedule every background batch with unique Workmanager names. ([33829f9b](https://github.com/ensembleUI/ensemble/commit/33829f9b49da513c587a2e603609cf8d1af49723)) + - **FIX**(navigation): clamp navigateViewGroup index before PageController.jumpToPage. ([0ce6723a](https://github.com/ensembleUI/ensemble/commit/0ce6723a14095823fb5d2f1ba3938c45f76c289d)) + - **FIX**(upload): complete cancelAll when some tasks are already completed. ([93cfc387](https://github.com/ensembleUI/ensemble/commit/93cfc387e700a5a1cb1c327b85f68a8ac56011d2)) + - **FIX**(storage): defer binding dispatches until public storage clear completes. ([8c2a52de](https://github.com/ensembleUI/ensemble/commit/8c2a52de1e84c078ff01905c3bce07428f5dc5ba)) + - **FIX**(listview): sync ListViewCore scroll controller when parent swaps it. ([c792a8a6](https://github.com/ensembleUI/ensemble/commit/c792a8a673196a1ae2eedf92ef488be438644602)) + - **FIX**(page): cancel header timers and dedupe storage event listeners. ([2c45e427](https://github.com/ensembleUI/ensemble/commit/2c45e4273678ff91007d486137e264dedc9c2251)) + - **FIX**(navigation): clamp ViewGroup tab index when payloads shrink. ([2b62b4a2](https://github.com/ensembleUI/ensemble/commit/2b62b4a2072862c27e9292ca0d38cefa1d1d9fad)) + - **FIX**(security): block path traversal in local bundled screen resolution. ([b317c925](https://github.com/ensembleUI/ensemble/commit/b317c9256d96cd01f3183866d3f5e38bfe9b6ff7)) + - **FIX**(layout): restore scroll controller when leaving footer scope. ([9f20ea43](https://github.com/ensembleUI/ensemble/commit/9f20ea43ddb389d6a718122dc01240fc5d3d3932)) + - **FIX**(listview): dispose owned scroll controller. ([e5d1c101](https://github.com/ensembleUI/ensemble/commit/e5d1c101080f49ac0596f09d93c1d2df4e4dff22)) + - **FIX**(security): reject unsafe screen selectors in remote definition fetches. ([db7de5b2](https://github.com/ensembleUI/ensemble/commit/db7de5b28f45c0dd2438ff7d0e1bea61bba1da6d)) + - **FIX**(security): sanitize saveFile names before writing to storage. ([a3db4674](https://github.com/ensembleUI/ensemble/commit/a3db467427bd473e3b1a02c113369bcdaa75b6a4)) + - **FIX**(security): stop WebView from bypassing TLS and unsafe-browsing defaults. ([a51ba979](https://github.com/ensembleUI/ensemble/commit/a51ba979ddb12fa3cb48af0f436e5a1cb1057fc7)) + - **FIX**(device): update screenOrientation to use enum name for clarity. ([41b9dee0](https://github.com/ensembleUI/ensemble/commit/41b9dee0c974a06882db5edbd1219084ad81fc9c)) + - **FIX**(cdn): reset invalid manifest cache state. ([4e060421](https://github.com/ensembleUI/ensemble/commit/4e060421ebf7ba2121284ba93c825db7370b8f2d)) + - **FEAT**(device): streamline MediaQuery capability and add device metric notifications. ([991650b6](https://github.com/ensembleUI/ensemble/commit/991650b6666e615eca2e9b7f8418af847a08d3d0)) + - **FEAT**(tab): fix persistentTabBar behavior with listview. ([ed51255a](https://github.com/ensembleUI/ensemble/commit/ed51255a0b8456c9ec3654f6ddce1e315cae9d77)) + - **FEAT**(tabbar): add indexed tab mode with on-demand tab building and caching. ([93cd430b](https://github.com/ensembleUI/ensemble/commit/93cd430b0b700ae123ddf378f627ab884067f515)) + - **FEAT**(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management. ([42669188](https://github.com/ensembleUI/ensemble/commit/42669188ea423c9dba5beac449ee38853f01b88b)) + - **FEAT**(tab): add persistentTabBar option for keep the tab pinned. ([bbafb0d1](https://github.com/ensembleUI/ensemble/commit/bbafb0d1c3be1f321cbe92f3cc0d8598e476dde0)) + - **DOCS**(ensemble): document storage.clear and multipart upload paths. ([d88fb624](https://github.com/ensembleUI/ensemble/commit/d88fb624b68136d3f006eac5ea7c6bc78752e1e2)) + - **DOCS**(ensemble): document runtime security and device metric bindings. ([b7087842](https://github.com/ensembleUI/ensemble/commit/b7087842440ae498e11e1f088ddbfcc3f8d7dc5b)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + - **DOCS**: move layout widget notes out of package readme. ([fc901707](https://github.com/ensembleUI/ensemble/commit/fc9017078bd2147c6486352e886a347a04ef6dcc)) + - **DOCS**: document layout widget scroll and tab behavior. ([ba64173e](https://github.com/ensembleUI/ensemble/commit/ba64173e4433c49d6907c78494970639d5a93748)) + ## 1.2.44 - **FIX**(upload): scope cancelAll to upload Workmanager tags only. ([4d763ac4](https://github.com/ensembleUI/ensemble/commit/4d763ac4c883363a96a6a5b8b3c264bf8fb3f0b3)) @@ -41,6 +72,20 @@ - **REFACTOR**(cdn): improve secret management and artifact handling in CdnDefinitionProvider. ([a2c87792](https://github.com/ensembleUI/ensemble/commit/a2c877925a4d2ac489aab8c5a89f3e66e4dd5a98)) - **FEAT**(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management. ([42669188](https://github.com/ensembleUI/ensemble/commit/42669188ea423c9dba5beac449ee38853f01b88b)) +## 1.2.38-beta.6 + + - releaseing new beta version + +## 1.2.38-beta.5 + + - Releasing new beta version for TV + +## 1.2.38-beta.4 + + - **FIX**(incorrect header format): explicit convertion of header entry into string. ([84ead788](https://github.com/ensembleUI/ensemble/commit/84ead788a7cd0b1aee50292eabf07bc8ae3c490d)) + - **FEAT**(image): enhance header evaluation for dynamic HTTP headers in image requests. ([e24544fe](https://github.com/ensembleUI/ensemble/commit/e24544fe2587a4119f6c4a8242e9b51948eef57c)) + - **FEAT**(image): add support for custom HTTP headers in image requests. ([3a304a2c](https://github.com/ensembleUI/ensemble/commit/3a304a2cd01a2af6046a327a9380cf259cb1e37e)) + ## 1.2.39 - **FIX**(incorrect header format): explicit convertion of header entry into string. ([84ead788](https://github.com/ensembleUI/ensemble/commit/84ead788a7cd0b1aee50292eabf07bc8ae3c490d)) @@ -52,6 +97,26 @@ - **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)) +## 1.2.38-beta.3 + + - **FIX**(invoke_api_action): handle FirestoreResponse in error handling. ([c8cb1c7b](https://github.com/ensembleUI/ensemble/commit/c8cb1c7b1bb97405bc61893338b1aeab53838a8f)) + - **FEAT**(dotenv): implement dotenv bundle parsing and refactor config loading. ([0a987c94](https://github.com/ensembleUI/ensemble/commit/0a987c94aef06e2b220fca85c1d8f43c707fd506)) + +## 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)) @@ -76,6 +141,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 eac1c4820..046eaae56 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), @@ -129,8 +133,31 @@ mixin DeviceInfoCapability { static MacOsDeviceInfo? macOsInfo; static WindowsDeviceInfo? windowsInfo; + // Android TV detection cache + static bool? _isTV; + DevicePlatform? get platform => _platform; + /// Returns true if the device is an Android TV + /// Checks for TV-specific system features + bool get isTV { + if (_isTV != null) return _isTV!; + + // Only Android devices can be TVs (for now) + if (kIsWeb || _platform != DevicePlatform.android || androidInfo == null) { + _isTV = false; + return false; + } + + // Check for TV system features + final systemFeatures = androidInfo!.systemFeatures; + _isTV = systemFeatures.contains('android.hardware.type.television') || + systemFeatures.contains('android.software.leanback') || + systemFeatures.contains('android.software.leanback_only'); + + return _isTV!; + } + /// initialize device info void initDeviceInfo() async { try { diff --git a/modules/ensemble/lib/framework/theme/theme_loader.dart b/modules/ensemble/lib/framework/theme/theme_loader.dart index 4362a76d7..ea4d3bb61 100644 --- a/modules/ensemble/lib/framework/theme/theme_loader.dart +++ b/modules/ensemble/lib/framework/theme/theme_loader.dart @@ -1,6 +1,7 @@ import 'package:ensemble/framework/extensions.dart'; import 'package:ensemble/framework/theme/default_theme.dart'; import 'package:ensemble/framework/theme/theme_manager.dart'; +import 'package:ensemble/framework/tv/tv_focus_theme.dart'; import 'package:ensemble/model/text_scale.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/image.dart'; @@ -23,6 +24,7 @@ mixin ThemeLoader { YamlMap? colorOverrides, YamlMap? screenOverrides, YamlMap? widgetOverrides, + YamlMap? tokensOverrides, }) { if (appOverrides == null) { @@ -37,6 +39,9 @@ mixin ThemeLoader { if (widgetOverrides == null) { widgetOverrides = overrides?['Widgets']; } + if (tokensOverrides == null) { + tokensOverrides = overrides?['Tokens']; + } final seedColor = Utils.getColor(colorOverrides?['seed']); String _defaultFontFamily = appOverrides?['fontFamily']?? appOverrides?['textStyle']?['fontFamily'] ?? 'Inter'; TextStyle? defaultFontFamily = Utils.getFontFamily(_defaultFontFamily) ?? TextStyle(); @@ -174,6 +179,7 @@ mixin ThemeLoader { loadingScreenIndicatorColor: Utils.getColor( colorOverrides?['loadingScreenIndicatorColor']), transitions: Utils.getMap(overrides?['Transitions']), + tvFocusTheme: _parseTVFocusTheme(tokensOverrides), ) ]); } @@ -509,6 +515,30 @@ mixin ThemeLoader { ///------------ publicly available theme getters ------------- BorderRadius getInputDefaultBorderRadius(InputVariant? variant) => BorderRadius.all(Radius.circular(variant == InputVariant.box ? 8 : 0)); + + /// Parses TV focus theme configuration from theme tokens. + /// + /// Looks for TV configuration under Tokens.TV in the theme YAML: + /// ```yaml + /// Tokens: + /// TV: + /// 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']), + ); + } } /// Configures image cache settings from App.imageCache in theme.yaml. @@ -548,26 +578,36 @@ extension CheckboxThemeDataExtension on CheckboxThemeData { /// extend Theme to add our own special color parameters class EnsembleThemeExtension extends ThemeExtension { - EnsembleThemeExtension( - {this.appTheme, - this.loadingScreenBackgroundColor, - this.loadingScreenIndicatorColor, - this.transitions}); + EnsembleThemeExtension({ + this.appTheme, + this.loadingScreenBackgroundColor, + this.loadingScreenIndicatorColor, + this.transitions, + this.tvFocusTheme, + }); final AppTheme? appTheme; final Color? loadingScreenBackgroundColor; final Color? loadingScreenIndicatorColor; // should deprecate this final Map? transitions; + /// TV focus styling configuration parsed from theme.yaml. + /// Used as the highest priority source for TV focus indicator styling. + final TVFocusTheme? tvFocusTheme; + @override - ThemeExtension copyWith( - {Color? loadingScreenBackgroundColor, - Color? loadingScreenIndicatorColor}) { + ThemeExtension copyWith({ + Color? loadingScreenBackgroundColor, + Color? loadingScreenIndicatorColor, + TVFocusTheme? tvFocusTheme, + }) { return EnsembleThemeExtension( - loadingScreenBackgroundColor: - loadingScreenBackgroundColor ?? this.loadingScreenBackgroundColor, - loadingScreenIndicatorColor: - loadingScreenIndicatorColor ?? this.loadingScreenIndicatorColor); + loadingScreenBackgroundColor: + loadingScreenBackgroundColor ?? this.loadingScreenBackgroundColor, + loadingScreenIndicatorColor: + loadingScreenIndicatorColor ?? this.loadingScreenIndicatorColor, + tvFocusTheme: tvFocusTheme ?? this.tvFocusTheme, + ); } @override @@ -581,6 +621,8 @@ class EnsembleThemeExtension extends ThemeExtension { loadingScreenBackgroundColor, other.loadingScreenBackgroundColor, t), loadingScreenIndicatorColor: Color.lerp( loadingScreenIndicatorColor, other.loadingScreenIndicatorColor, t), + // TV focus theme doesn't need lerping - use target value + tvFocusTheme: t < 0.5 ? tvFocusTheme : other.tvFocusTheme, ); } } diff --git a/modules/ensemble/lib/framework/theme_manager.dart b/modules/ensemble/lib/framework/theme_manager.dart index 2255c47b9..cc713ff3c 100644 --- a/modules/ensemble/lib/framework/theme_manager.dart +++ b/modules/ensemble/lib/framework/theme_manager.dart @@ -349,9 +349,17 @@ class EnsembleTheme { initialized = true; return this; } + /// Initialize app-level ThemeData with tokens and styles. + /// Pass tokensOverrides so TV-related tokens (e.g., Tokens.TV.focusColor) + /// are parsed and included in the theme's EnsembleThemeExtension. void initAppThemeData() { YamlMap? yamlStyles = styles != null ? YamlMap.wrap(styles) : null; - appThemeData = ThemeManager().getAppTheme(yamlStyles,widgetOverrides: yamlStyles); + YamlMap? yamlTokens = tokens.isNotEmpty ? YamlMap.wrap(tokens) : null; + appThemeData = ThemeManager().getAppTheme( + yamlStyles, + widgetOverrides: yamlStyles, + tokensOverrides: yamlTokens, + ); } Map? getIDStyles(String? id) { return (id == null) ? {} : styles['#$id']; diff --git a/modules/ensemble/lib/framework/tv/tv_focus_order.dart b/modules/ensemble/lib/framework/tv/tv_focus_order.dart new file mode 100644 index 000000000..df807494f --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_order.dart @@ -0,0 +1,199 @@ +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, + lockHorizontalNavigation = false, + delegateHorizontalNavigation = 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, + this.lockHorizontalNavigation = false, + this.delegateHorizontalNavigation = 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; + + /// If true, prevents horizontal navigation from escaping this row at boundaries. + /// When at the first item, LEFT won't propagate; when at the last item, RIGHT won't propagate. + final bool lockHorizontalNavigation; + + /// If true, horizontal navigation (LEFT/RIGHT) is delegated to the parent FocusScope. + /// Use this for items inside carousels where horizontal keys should switch slides. + final bool delegateHorizontalNavigation; + + /// Composite value for sorting: row * 10000 + order + /// This ensures items are sorted by row first, then by order within row + double get value => row * 10000 + order; + + @override + int doCompare(TVFocusOrder other) => value.compareTo(other.value); + + /// Create a new TVFocusOrder offset from this one + TVFocusOrder offset({double rowOffset = 0, double orderOffset = 0}) { + return TVFocusOrder.withOptions( + row + rowOffset, + order: order + orderOffset, + page: page, + pagePixels: pagePixels, + isRowEntryPoint: isRowEntryPoint, + lockHorizontalNavigation: lockHorizontalNavigation, + delegateHorizontalNavigation: delegateHorizontalNavigation, + ); + } + + /// 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 and handle edge navigation. +/// Useful for dialogs, modal content, and providing edge handlers for scrollbars. +class TVFocusScope extends FocusScope { + /// If true, focus cannot escape this scope + final bool lockScope; + + /// Optional callback when RIGHT is pressed at the rightmost edge + final VoidCallback? onRightEdge; + + /// Optional callback when LEFT is pressed at the leftmost edge + final VoidCallback? onLeftEdge; + + /// Optional callback when UP is pressed at the topmost edge + final VoidCallback? onTopEdge; + + /// Optional callback when DOWN is pressed at the bottommost edge + final VoidCallback? onBottomEdge; + + const TVFocusScope({ + super.key, + required this.lockScope, + required super.child, + super.debugLabel, + this.onRightEdge, + this.onLeftEdge, + this.onTopEdge, + this.onBottomEdge, + }); +} diff --git a/modules/ensemble/lib/framework/tv/tv_focus_provider.dart b/modules/ensemble/lib/framework/tv/tv_focus_provider.dart new file mode 100644 index 000000000..e10762fe5 --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_provider.dart @@ -0,0 +1,202 @@ +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, +/// bool lockHorizontalNavigation = false, +/// KeyEventResult Function(FocusNode)? onBackPressed, +/// }) { +/// return PageFocusWidget( +/// focusOrder: PageFocusOrder( +/// row, order, +/// isRowEntryPoint: isRowEntryPoint, +/// lockHorizontalNavigation: lockHorizontalNavigation, +/// ), +/// onBackPressed: onBackPressed, +/// child: child, +/// ); +/// } +/// } +/// +/// // 2. Provide it to Ensemble +/// EnsembleWrapper( +/// tvFocusProvider: MyFocusProvider(), +/// child: EnsembleScreen(...), +/// ) +/// ``` +abstract class TVFocusProvider { + /// Creates a focusable widget wrapper with the given coordinates. + /// + /// Parameters: + /// - [row]: Vertical position in the focus grid (0, 1, 2, ...) + /// - [order]: Horizontal position within the row (0, 1, 2, ...) + /// - [isRowEntryPoint]: If true, this is the preferred focus target when + /// navigating INTO this row from another row. Useful for tabs where + /// the selected tab should receive focus. + /// - [lockHorizontalNavigation]: If true, prevents focus from escaping + /// this row at horizontal boundaries (left/right edges). Useful for + /// horizontal lanes where focus should stay locked within the row. + /// - [delegateHorizontalNavigation]: If true, horizontal navigation events + /// (LEFT/RIGHT) are delegated to the parent FocusScope instead of being + /// handled locally. Useful for items inside carousels where horizontal + /// keys should switch slides. + /// - [child]: The widget to make focusable. Should contain an InkWell + /// or similar focusable widget. + /// - [onBackPressed]: Optional callback for Android TV back button. + /// - [onRightEdge]: Optional callback when at right edge of grid and RIGHT pressed. + /// Used for scrollbar navigation from ListView content. + /// - [onLeftEdge]: Optional callback when at left edge of grid and LEFT pressed. + /// Used for left-positioned scrollbar navigation. + /// - [onTopEdge]: Optional callback when at top edge and UP pressed. + /// - [onBottomEdge]: Optional callback when at bottom edge and DOWN pressed. + /// + /// The returned widget should: + /// - Handle D-pad key events (UP/DOWN/LEFT/RIGHT) + /// - Participate in the host app's focus traversal grid + /// - Support auto-scrolling to keep focused item visible + /// - Call edge handlers when navigation reaches grid boundaries + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + KeyEventResult Function(FocusNode node)? onBackPressed, + VoidCallback? onRightEdge, + VoidCallback? onLeftEdge, + VoidCallback? onTopEdge, + VoidCallback? onBottomEdge, + }); + + /// Optional: Row offset for Ensemble content. + /// + /// Ensemble's YAML-defined `tvRow` values are relative (0, 1, 2...). + /// This offset is added to create absolute positions in the host app's grid. + /// + /// Example: If host app's tab bar is at row 0, set rowOffset to 1 + /// so Ensemble content starts at row 1. + /// + /// Default: 0 (no offset) + double get rowOffset => 0; + + /// Optional: Order (horizontal) offset for Ensemble content. + /// + /// Ensemble's YAML-defined `tvOrder` values are relative (0, 1, 2...). + /// This offset is added to create absolute positions in the host app's grid. + /// + /// Example: If Sports tab is at order 5, set orderOffset to 5 + /// so navigating UP from Ensemble naturally lands on the Sports tab. + /// + /// Default: 0 (no offset) + double get orderOffset => 0; + + // ───────────────────────────────────────────────────────────────────────── + // TV Focus Styling (optional overrides from host app) + // Priority: Ensemble Theme > Provider > Default fallback + // ───────────────────────────────────────────────────────────────────────── + + /// Optional: Focus indicator border color. + /// + /// When provided, overrides Ensemble's default focus 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..ef1e0ea7a --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_focus_widget.dart @@ -0,0 +1,315 @@ +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 +/// - Supports edge handlers for navigating to widgets outside the grid (e.g., scrollbars) +/// +/// IMPORTANT: The inner Focus widget uses `skipTraversal: true` (not `canRequestFocus: false`) +/// so that it still receives key events but doesn't participate in tab navigation. +/// Using `canRequestFocus: false` would cause Flutter to skip this widget entirely +/// in the key event routing chain, breaking D-pad navigation. +class TVFocusWidget extends StatelessWidget { + const TVFocusWidget({ + super.key, + required this.focusOrder, + required this.child, + this.onBackPressed, + this.onRightEdge, + this.onLeftEdge, + this.onTopEdge, + this.onBottomEdge, + }); + + /// The focus coordinate for this widget + final TVFocusOrder focusOrder; + + /// The child widget (should be focusable, e.g., InkWell) + final Widget child; + + /// Optional callback when back button is pressed + final KeyEventResult Function(FocusNode node)? onBackPressed; + + /// Optional callback when RIGHT is pressed at the rightmost edge + /// (when no more items exist in the row). Used for navigating to + /// widgets outside the grid like scrollbars. + final VoidCallback? onRightEdge; + + /// Optional callback when LEFT is pressed at the leftmost edge + final VoidCallback? onLeftEdge; + + /// Optional callback when UP is pressed at the topmost edge + final VoidCallback? onTopEdge; + + /// Optional callback when DOWN is pressed at the bottommost edge + final VoidCallback? onBottomEdge; + + @override + Widget build(BuildContext context) { + return FocusTraversalOrder( + order: focusOrder, + // Use FocusScope instead of Focus so that this node becomes the PARENT + // of the child's focus node in the focus tree. This allows key events + // from the child to bubble up through this handler. + // With a plain Focus widget, this node would be a SIBLING to the child's + // focus node, and key events would bypass it entirely. + child: FocusScope( + onKeyEvent: (FocusNode node, KeyEvent event) { + if (event is KeyDownEvent) { + // Handle back button + if (event.logicalKey == LogicalKeyboardKey.goBack) { + final result = onBackPressed?.call(node); + if (result != null) { + return result; + } + } + + // Handle arrow keys + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_moveFocus(context, node, yOffset: 1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_moveFocus(context, node, yOffset: -1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_moveFocus(context, node, xOffset: 1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_moveFocus(context, node, xOffset: -1)) { + return KeyEventResult.handled; + } + } + } + return KeyEventResult.ignored; + }, + child: child, + ), + ); + } + + /// Move focus in the specified direction. + /// Returns true if focus was moved, false if at boundary. + bool _moveFocus( + BuildContext context, + FocusNode current, { + int yOffset = 0, + int xOffset = 0, + }) { + // If delegateHorizontalNavigation is true, let parent handle horizontal keys + // (e.g., for carousel slide switching) + if (xOffset != 0 && focusOrder.delegateHorizontalNavigation) { + return false; + } + + // Find the FocusTraversalGroup this widget belongs to + final focusTraversalGroup = + current.context?.findAncestorWidgetOfExactType(); + + // Check for scope locking + final tvFocusScope = + current.context?.findAncestorWidgetOfExactType(); + final lockScope = tvFocusScope?.lockScope ?? false; + + // 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; + } + + // Check if trying to exit at top boundary (UP and at first row in grid) + // Let the event propagate to native focus handling (e.g., sport tab) + // so users can navigate back to native content from Ensemble content + if (yOffset == -1 && y == 0) { + 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; + } + + // Handle horizontal boundary locking (prevents escaping row at left/right edges) + if (xOffset != 0 && focusOrder.lockHorizontalNavigation) { + // At horizontal boundary with lockHorizontalNavigation enabled - block the event + return true; + } + + // Check for edge handlers before letting event propagate + // This allows navigation to widgets outside the grid (e.g., scrollbars) + // Priority: widget-level handlers > scope-level handlers + final rightEdgeHandler = onRightEdge ?? tvFocusScope?.onRightEdge; + final leftEdgeHandler = onLeftEdge ?? tvFocusScope?.onLeftEdge; + final bottomEdgeHandler = onBottomEdge ?? tvFocusScope?.onBottomEdge; + final topEdgeHandler = onTopEdge ?? tvFocusScope?.onTopEdge; + + if (xOffset > 0 && rightEdgeHandler != null) { + // At right edge and have handler + debugPrint('[TVFocusWidget] At right edge - calling onRightEdge handler'); + rightEdgeHandler(); + return true; + } else if (xOffset < 0 && leftEdgeHandler != null) { + // At left edge and have handler + debugPrint('[TVFocusWidget] At left edge - calling onLeftEdge handler'); + leftEdgeHandler(); + return true; + } else if (yOffset > 0 && bottomEdgeHandler != null) { + // At bottom edge and have handler + debugPrint('[TVFocusWidget] At bottom edge - calling onBottomEdge handler'); + bottomEdgeHandler(); + return true; + } else if (yOffset < 0 && topEdgeHandler != null) { + // At top edge and have handler + debugPrint('[TVFocusWidget] At top edge - calling onTopEdge handler'); + topEdgeHandler(); + 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/tv/tv_scrollbar_widget.dart b/modules/ensemble/lib/framework/tv/tv_scrollbar_widget.dart new file mode 100644 index 000000000..416dc9ebf --- /dev/null +++ b/modules/ensemble/lib/framework/tv/tv_scrollbar_widget.dart @@ -0,0 +1,248 @@ +import 'package:ensemble/framework/device.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Focusable scrollbar widget for TV navigation. +/// +/// This widget: +/// - Renders a vertical scrollbar with two visual states (normal/focused) +/// - Handles UP/DOWN key events to manually scroll content +/// - Listens to ScrollController to auto-sync thumb position +/// - Receives focus via TVFocusScope edge handlers when user navigates to boundary +/// - Returns focus to content based on scrollbar position (LEFT for right scrollbar, RIGHT for left scrollbar) +/// +/// Visual States: +/// - Normal: Grey color, thin width (e.g., 3px) +/// - Focused: White color, wider width (e.g., 6px) +/// +/// Navigation: +/// - Right-positioned scrollbar: RIGHT key at content edge → Scrollbar, LEFT returns to content +/// - Left-positioned scrollbar: LEFT key at content edge → Scrollbar, RIGHT returns to content +class TVScrollbarWidget extends StatefulWidget { + const TVScrollbarWidget({ + super.key, + required this.scrollController, + required this.options, + }); + + /// ScrollController from the scrollable content (ListView/Column) + final ScrollController scrollController; + + /// Scrollbar styling options from YAML + final TVScrollbarOptionsComposite options; + + @override + State createState() => _TVScrollbarWidgetState(); +} + +class _TVScrollbarWidgetState extends State { + late final FocusNode _focusNode; + bool _isFocused = false; + double _thumbOffset = 0.0; + double _thumbHeight = 0.0; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'TVScrollbar'); + widget.scrollController.addListener(_onScrollChange); + + debugPrint('[TVScrollbar] initState - position=${widget.options.position}'); + } + + /// Public method to request focus on this scrollbar (called from ListView) + void requestFocusOnScrollbar() { + debugPrint('[TVScrollbar] requestFocusOnScrollbar() called'); + _focusNode.requestFocus(); + } + + @override + void dispose() { + widget.scrollController.removeListener(_onScrollChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onScrollChange() { + if (mounted && widget.scrollController.hasClients) { + setState(() { + _updateThumbPosition(); + }); + } + } + + void _updateThumbPosition() { + if (!widget.scrollController.hasClients) return; + + final position = widget.scrollController.position; + final viewportHeight = position.viewportDimension; + final contentHeight = position.maxScrollExtent + viewportHeight; + final scrollOffset = position.pixels; + + // Calculate thumb height (proportional to viewport/content ratio) + final thumbRatio = viewportHeight / contentHeight; + _thumbHeight = (viewportHeight * thumbRatio).clamp( + widget.options.thumbHeight, + viewportHeight, + ); + + // Calculate thumb offset based on scroll position + final maxThumbOffset = viewportHeight - _thumbHeight; + final scrollRatio = contentHeight > viewportHeight + ? scrollOffset / (contentHeight - viewportHeight) + : 0.0; + _thumbOffset = (maxThumbOffset * scrollRatio).clamp(0.0, maxThumbOffset); + + // Removed excessive logging - thumb updates constantly during scroll + } + + void _scrollDown() { + if (!widget.scrollController.hasClients) { + debugPrint('[TVScrollbar] _scrollDown - no scroll controller clients'); + return; + } + + final position = widget.scrollController.position; + final viewportHeight = position.viewportDimension; + final scrollStep = viewportHeight * 0.2; // Scroll 20% of viewport + + final currentOffset = position.pixels; + final newOffset = (currentOffset + scrollStep).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + + debugPrint('[TVScrollbar] Scrolling DOWN: $currentOffset → $newOffset'); + + widget.scrollController.animateTo( + newOffset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + void _scrollUp() { + if (!widget.scrollController.hasClients) { + debugPrint('[TVScrollbar] _scrollUp - no scroll controller clients'); + return; + } + + final position = widget.scrollController.position; + final viewportHeight = position.viewportDimension; + final scrollStep = viewportHeight * 0.2; // Scroll 20% of viewport + + final currentOffset = position.pixels; + final newOffset = (currentOffset - scrollStep).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + + debugPrint('[TVScrollbar] Scrolling UP: $currentOffset → $newOffset'); + + widget.scrollController.animateTo( + newOffset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + // Only render on TV + if (!Device().isTV) { + return const SizedBox.shrink(); + } + + // Calculate current thumb position for first render + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.scrollController.hasClients) { + _updateThumbPosition(); + } + }); + + // Focus is requested via TVFocusScope edge handlers when user navigates to content boundary + return LayoutBuilder( + builder: (context, constraints) { + final trackHeight = constraints.maxHeight; + + // Use Focus widget with onKeyEvent for UP/DOWN scrolling + // InkWell provides focusability and integrates with directional focus + return Focus( + onKeyEvent: (node, event) { + // Only handle when we have focus + if (!_isFocused || event is! KeyDownEvent) return KeyEventResult.ignored; + + // Handle UP/DOWN for manual scrolling + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + debugPrint('[TVScrollbar] DOWN key - scrolling down'); + _scrollDown(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + debugPrint('[TVScrollbar] UP key - scrolling up'); + _scrollUp(); + return KeyEventResult.handled; + } + + // Handle LEFT/RIGHT to return focus to content based on scrollbar position + // When scrollbar is on right, LEFT returns to content + // When scrollbar is on left, RIGHT returns to content + if (widget.options.position == 'right' && + event.logicalKey == LogicalKeyboardKey.arrowLeft) { + debugPrint('[TVScrollbar] LEFT key - returning focus to content (scrollbar on right)'); + return KeyEventResult.ignored; // Let focus system handle it + } else if (widget.options.position == 'left' && + event.logicalKey == LogicalKeyboardKey.arrowRight) { + debugPrint('[TVScrollbar] RIGHT key - returning focus to content (scrollbar on left)'); + return KeyEventResult.ignored; // Let focus system handle it + } + + return KeyEventResult.ignored; + }, + child: InkWell( + focusNode: _focusNode, + autofocus: widget.options.autofocus, + onTap: () { + debugPrint('[TVScrollbar] Tapped'); + }, + onFocusChange: (hasFocus) { + debugPrint('[TVScrollbar] onFocusChange: $hasFocus'); + if (mounted) { + setState(() { + _isFocused = hasFocus; + }); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: _isFocused ? widget.options.focusedWidth : widget.options.width, + height: trackHeight, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(widget.options.radius), + ), + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 150), + left: 0, + top: _thumbOffset, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: _isFocused ? widget.options.focusedWidth : widget.options.width, + height: _thumbHeight, + decoration: BoxDecoration( + color: _isFocused ? widget.options.focusedColor : widget.options.color, + borderRadius: BorderRadius.circular(widget.options.radius), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/modules/ensemble/lib/framework/view/page.dart b/modules/ensemble/lib/framework/view/page.dart index 34a41aa6c..8f95a2b3c 100644 --- a/modules/ensemble/lib/framework/view/page.dart +++ b/modules/ensemble/lib/framework/view/page.dart @@ -25,6 +25,9 @@ import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:ensemble/widget/helpers/unfocus.dart'; import 'package:ensemble/framework/bindings.dart'; +import 'package:ensemble/framework/device.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; import 'package:flutter/material.dart'; class SinglePageController extends WidgetController { @@ -785,6 +788,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/framework/widget/widget.dart b/modules/ensemble/lib/framework/widget/widget.dart index 6e580e633..142a45034 100644 --- a/modules/ensemble/lib/framework/widget/widget.dart +++ b/modules/ensemble/lib/framework/widget/widget.dart @@ -1,5 +1,6 @@ import 'package:ensemble/framework/bindings.dart'; import 'package:ensemble/framework/config.dart'; +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/framework/studio/studio_debugger.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; @@ -114,8 +115,16 @@ abstract class EWidgetState // Handle standalone opacity // Apply only if visibilityTransitionDuration is NOT set (to avoid double wrapping) + // TV: Skip if tvOptions.opacity is set (wrapper handles both focused/unfocused) + final tvOptions = widgetController is BoxController + ? widgetController.tvOptions + : null; + final bool tvHandlesOpacity = Device().isTV && + tvOptions?.isEnabled == true && + tvOptions?.opacity != null; if (widgetController.visibilityTransitionDuration == null && - widgetController.opacity != null) { + widgetController.opacity != null && + !tvHandlesOpacity) { rtn = Opacity( opacity: Utils.optionalDouble(widgetController.opacity!, min: 0, max: 1.0) ?? 1.0, child: rtn, diff --git a/modules/ensemble/lib/layout/grid_view.dart b/modules/ensemble/lib/layout/grid_view.dart index 15fd80c9f..604d83ec2 100644 --- a/modules/ensemble/lib/layout/grid_view.dart +++ b/modules/ensemble/lib/layout/grid_view.dart @@ -329,15 +329,24 @@ class GridViewState extends EWidgetState with TemplatedWidgetState { ScreenController() .executeAction(context, widget._controller.onScrollEnd!); } + + // Build widget first to handle null case (e.g., bad template data) + // before wrapping with gesture detector + Widget? itemWidget = buildWidgetForIndex( + context, _items, widget._controller.itemTemplate!, index); + + if (itemWidget == null) { + return const SizedBox.shrink(); + } + if (widget._controller.onItemTap != null) { - return EnsembleGestureDetector( + itemWidget = EnsembleGestureDetector( onTap: (() => _onItemTap(index)), - child: buildWidgetForIndex( - context, _items, widget._controller.itemTemplate!, index), + child: itemWidget, ); } - return buildWidgetForIndex( - context, _items, widget._controller.itemTemplate!, index); + + return itemWidget; } void _onItemTap(int index) { diff --git a/modules/ensemble/lib/layout/list_view.dart b/modules/ensemble/lib/layout/list_view.dart index 94f73163b..6731975e0 100644 --- a/modules/ensemble/lib/layout/list_view.dart +++ b/modules/ensemble/lib/layout/list_view.dart @@ -1,9 +1,12 @@ import 'package:ensemble/action/haptic_action.dart'; import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/error_handling.dart'; import 'package:ensemble/framework/event.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/framework/studio/studio_debugger.dart'; +import 'package:ensemble/framework/tv/tv_scrollbar_widget.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/framework/view/footer.dart'; import 'package:ensemble/framework/widget/has_children.dart'; @@ -402,6 +405,68 @@ class ListViewState extends EWidgetState options: pullToRefresh!, contentWidget: listView); } + // TV: Add focusable scrollbar if configured + if (Device().isTV && widget._controller.tvOptions?.scrollbarOptions != null) { + debugPrint('[ListView] TV scrollbar enabled - using edge handler approach'); + final scrollbarOptions = widget._controller.tvOptions!.scrollbarOptions!; + final scrollController = widget._controller.scrollController; + + if (scrollController != null) { + debugPrint('[ListView] Creating TVScrollbarWidget - position: ${scrollbarOptions.position}'); + + // Store scrollbar widget with key to access later + final scrollbarKey = flutter.GlobalKey>(); + final scrollbarWidget = TVScrollbarWidget( + key: scrollbarKey, + scrollController: scrollController, + options: scrollbarOptions, + ); + + // Callback to request focus on scrollbar + void requestScrollbarFocus() { + final scrollbarState = scrollbarKey.currentState; + if (scrollbarState != null) { + debugPrint('[ListView] Edge handler called - requesting focus on scrollbar'); + (scrollbarState as dynamic).requestFocusOnScrollbar(); + } + } + + // Determine scrollbar position and which edge handler to use + final isLeftPosition = scrollbarOptions.position == 'left'; + + // Wrap content with TVFocusScope that handles edge navigation + final scopedContent = TVFocusScope( + lockScope: false, + // Set edge handler based on scrollbar position + onRightEdge: isLeftPosition ? null : requestScrollbarFocus, + onLeftEdge: isLeftPosition ? requestScrollbarFocus : null, + child: listView, + ); + + // Build Row with scrollbar on correct side + listView = flutter.Row( + crossAxisAlignment: flutter.CrossAxisAlignment.stretch, + children: isLeftPosition + ? [ + flutter.FocusTraversalGroup( + policy: flutter.WidgetOrderTraversalPolicy(), + child: scrollbarWidget, + ), + flutter.Expanded(child: scopedContent), + ] + : [ + flutter.Expanded(child: scopedContent), + flutter.FocusTraversalGroup( + policy: flutter.WidgetOrderTraversalPolicy(), + child: scrollbarWidget, + ), + ], + ); + } else { + debugPrint('[ListView] WARNING: scrollbarOptions defined but scrollController is null!'); + } + } + return BoxWrapper( boxController: widget._controller, widget: DefaultTextStyle.merge( diff --git a/modules/ensemble/lib/layout/tab/base_tab_bar.dart b/modules/ensemble/lib/layout/tab/base_tab_bar.dart index 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 6c7e7a4a7..2b51acd13 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; @@ -68,6 +73,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..a0d90b1cd 100644 --- a/modules/ensemble/lib/widget/button.dart +++ b/modules/ensemble/lib/widget/button.dart @@ -1,5 +1,6 @@ import 'package:ensemble/action/haptic_action.dart'; import 'package:ensemble/framework/action.dart' as ensemble; +import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/theme/theme_manager.dart'; import 'package:ensemble/framework/view/data_scope_widget.dart'; import 'package:ensemble/layout/form.dart'; @@ -134,6 +135,7 @@ class ButtonState extends EWidgetState