Skip to content

feat: Add Android TV D-pad navigation support with tvOptions - #2173

Merged
usmanvrtx merged 200 commits into
mainfrom
android_TV_implementation
Nov 10, 2026
Merged

usmanvrtx merged 200 commits into
mainfrom
android_TV_implementation

Conversation

@TheNoumanDev

@TheNoumanDev TheNoumanDev commented Mar 6, 2026 •

Copy link
Copy Markdown
Member

Summary

Add D-pad focus navigation system for Android TV with coordinate-based tvOptions in YAML. All TV code gated behind Device().isTV check.

Key Changes

Core TV Framework

  • tv_focus_widget.dart - Built-in D-pad navigation with grid-based focus traversal
  • tv_focus_provider.dart - Interface for host app focus integration
  • tv_focus_order.dart - Coordinate system (row, order, isRowEntryPoint)
  • tv_focus_theme.dart - Focus styling resolution (theme > provider > primary color)
  • tv_scrollbar_widget.dart - Focusable scrollbar for ListView on TV

Widget Support

  • box_wrapper.dart - TV focus for BoxController widgets (~30 widgets)
  • form_helper.dart + input_wrapper.dart - TV focus for form widgets (~10 widgets)
  • controllers.dart - TVOptionsComposite with focus styling

Layout Support

  • list_view.dart - Focusable scrollbar (scrollbarOptions) with fallback (direct) focus when the list has no focusable items
  • carousel.dart - delegateHorizontalNavigation, interceptHorizontalNav
  • tab_bar.dart + base_tab_bar.dart - TV tab navigation with tabWidget support, plus focusIndicatorColor / focusTabColor / tvRow
  • grid_view.dart - TV focus coordinates
  • bracket - TV D-pad navigation for tournaments

Theme Integration

  • theme_loader.dart - Parse Tokens.TV.* for focus styling
  • device.dart - isTV getter for Android TV detection

Focus Styling Properties under tvOptions

row, order, isRowEntryPoint, rememberRowPosition, focusGroup, edges, focusBorderColor, focusBorderWidth, focusBorderRadius, scale, elevation, opacity

Example

Button:
  label: Play
  styles:
    tvOptions:
      row: 1
      order: 0
      isRowEntryPoint: true
      scale: 1.05

Developer docs

Comprehensive developer guide: modules/ensemble/doc/tv_developer_guide.md

Non-TV Changes (this branch)

Cross-platform changes not behind the TV flag:

New properties:

  • Image & Avatar — allowRedirect to block HTTP redirects (new RedirectBlockingHttpClient, redirect-keyed cache)
  • Carousel — indicatorLeadingWidget / indicatorTrailingWidget
  • Button — autofocus: true" and "default false, so existing buttons are unchanged"; base-widget autofocus + ${widget.hasFocus} getter
  • Bracket — scale layout knob, opt-in tab borders (borderColor / borderWidth / selectedBorderColor / selectedBorderWidth), roundIndex / matchIndex template bindings

Improvements & fixes:

  • Bracket — connector length now computed from card spacing so lines connect at any depth/scale (was a fixed value), shouldRepaint returns real conditions, crash guards (_scrollToSelectedTab, _onPageChanged), default match scope-name typo march → match
  • GridView & TabBar — null-safety crash guards
  • Framework — theme-override plumbing (tokensOverrides), controller id propagation, external-navigation theming

Demo

Video: https://drive.google.com/file/d/1mZfg8hNFv9m0QkUy0S3P1C4Xkt287tfc/view?usp=sharing

To test the KPN Android TV app, download this APK and install on the Tv emulator or real device: https://dev.azure.com/kpn/_apis/resources/Containers/28001799/android_apk?itemPath=android_apk%2Fapp-kpnandroidtvstaging-release.apk

@TheNoumanDev
TheNoumanDev force-pushed the android_TV_implementation branch from 0ec7c68 to 30b28fa Compare April 6, 2026 08:46
@TheNoumanDev
TheNoumanDev force-pushed the android_TV_implementation branch from 9761dfb to 59a05bc Compare April 6, 2026 09:35
@TheNoumanDev
TheNoumanDev force-pushed the android_TV_implementation branch from 93bef64 to 7fd5f77 Compare April 6, 2026 09:43
usmanvrtx and others added 26 commits October 14, 2026 21:24
The merge of main into android_TV_implementation landed the flutter-upgrade
TODO/ignore block below the cacheExtent argument, so the
`// ignore: deprecated_member_use` no longer applied to it and the comment
merged into the padding explanation. Move the block back above the argument
and drop the now-inaccurate const from the suggested ScrollCacheExtent call,
since the extent is user-configurable.

Comment-only change; no behavior change.
…tion

Brings in PR #2387 (page-scoped PageEventBus, API disposal guards, storage
cleanup).

Conflict resolved in modules/ensemble/lib/framework/scope.dart: kept the
branch's shared-resource dispose split and adopted the incoming isDisposed
semantics. pageData._disposed is set on the root-scope path only, because
child scopes already early-return and must not mark the page disposed. That
keeps PR #2387's API onResponse/onError disposal guards effective while
child-scope teardown still leaves the page alive.

The incoming PageEventBus model indexing applies unchanged in the branch's
_listenToBindingSource (the incoming change targeted the pre-branch method
name listen()).
feat(tv): add opt-in focusHighlightColor, outside and gap scrollbar options
In the default focus-scroll mode, the whole-page pager reveal and the
enclosing horizontal lane scroll ran together, both measured from the
item's pre-move position. A peeking slide inside a horizontal lane was
therefore moved twice and focus ended off screen. Run the lane scroll
after the page move completes and is laid out, and only if the item is
still focused.
fix(tv): preserve pager alignment during focus scroll
fix: scroll tab content back to the top when Back returns focus to the tab
fix: keep carousel state and focus through rebuilds and slide changes
…a new widget instance

EWidgetState.didUpdateWidget released the old widget instance's binding owner,
which cancelled every subscription keyed on that instance. Widgets that
subscribe through a direct listen() call (Conditional, TabBar, item templates)
register once in didChangeDependencies, keyed on the widget they had at that
time. When a parent rebuilt its subtree from the model, Flutter kept the child
State but handed it a new widget instance, so the child lost its only
subscriptions and never reacted to data changes again. In the KPN InHome app
this left a Conditional on ensemble.storage.hasDnsBackup frozen after the
RestoreSettings screen's load APIs responded, so the restored-settings UI never
updated.

Introduce PageBindingManager.transferBindingOwner: cancel the old instance's
managed subscriptions (the new instance re-registered its own while it was
built), re-key the remaining direct subscriptions to the new instance and move
the owner count with them so dispose() still releases everything.

Add a regression test with a nested Conditional under a rebuilding parent that
checks reactivity, a stable listener count across repeated rebuilds and full
cleanup on unmount.
…isteners

fix(scope): keep direct listen() subscriptions when a State receives a new widget instance
@usmanvrtx
usmanvrtx requested review from sharjeelyunus and removed request for sharjeelyunus November 10, 2026 14:34
@usmanvrtx
usmanvrtx merged commit 5c06412 into main Nov 10, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants