fix(tv): hug poster with focus border and add focus glow toggle

close #1278
This commit is contained in:
edde746
2026-06-12 08:50:29 +02:00
parent cceba28d0a
commit 587609d7f6
46 changed files with 594 additions and 400 deletions
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'focus_theme.dart';
/// Exposes the focus state of an enclosing focus wrapper to a descendant
/// [CardFocusBorder] that draws the focus border itself.
///
/// Wrappers ([FocusableWrapper]/[FocusBuilders.buildFocusableCard]) insert this
/// instead of painting a border when `delegateFocusBorder` is set, so cards can
/// put the border on the exact rect the design highlights (the poster image,
/// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder]
/// element registers a dependency, so a focus flip rebuilds just that border
/// box: a stable `child:` card subtree (e.g. the TV rail's MediaCard) is not
/// rebuilt.
class CardFocusScope extends InheritedWidget {
const CardFocusScope({super.key, required this.showFocus, required super.child});
/// Whether the enclosing wrapper currently shows focus visuals
/// (focused while in keyboard/d-pad input mode).
final bool showFocus;
/// Null when no delegating wrapper is above (touch mode skips the focus
/// wrappers entirely) — [CardFocusBorder] then renders its child bare.
static bool? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType<CardFocusScope>()?.showFocus;
@override
bool updateShouldNotify(CardFocusScope oldWidget) => showFocus != oldWidget.showFocus;
}
/// Draws the focus border around its child based on the enclosing
/// [CardFocusScope], letting the card decide which rect gets highlighted.
///
/// Defaults to an outside stroke so the border hugs the child exactly like the
/// full-bleed card treatment (the child's own corner radius nests inside it).
/// Decoration only — never affects layout. Renders the child unchanged when no
/// scope is present.
class CardFocusBorder extends StatelessWidget {
const CardFocusBorder({
super.key,
required this.borderRadius,
this.strokeAlign = BorderSide.strokeAlignOutside,
required this.child,
});
final double borderRadius;
final double strokeAlign;
final Widget child;
@override
Widget build(BuildContext context) {
final showFocus = CardFocusScope.maybeOf(context);
if (showFocus == null) return child;
return AnimatedContainer(
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
foregroundDecoration: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: borderRadius,
borderStrokeAlign: strokeAlign,
),
child: child,
);
}
}
+9 -5
View File
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../services/device_performance.dart';
import '../services/settings_service.dart';
import 'focus_theme.dart';
/// Renders the focus glow for a focused card in the root [Overlay] so it paints
@@ -56,10 +57,15 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
/// fades out before the portal is hidden in [_handleFadeEnd].
bool _visible = false;
/// Glow is skipped on the reduced effects tier (blurred shadows + fade
/// saveLayer are too expensive on weak GPUs) and when the user turned the
/// Focus Glow setting off (#1278). The crisp focus border remains.
static bool get _disabled => DevicePerformance.isReduced || !SettingsService.instance.read(SettingsService.focusGlow);
@override
void initState() {
super.initState();
if (widget.isFocused && !DevicePerformance.isReduced) {
if (widget.isFocused && !_disabled) {
_visible = true;
_controller.show();
}
@@ -68,7 +74,7 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
@override
void didUpdateWidget(FocusGlowOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (DevicePerformance.isReduced) return;
if (_disabled) return;
if (widget.isFocused == oldWidget.isFocused) return;
if (widget.isFocused) {
_controller.show();
@@ -92,9 +98,7 @@ class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
@override
Widget build(BuildContext context) {
// Reduced tier: no glow at all — the blurred shadows + fade saveLayer are
// too expensive on weak GPUs. The crisp in-card focus border remains.
if (DevicePerformance.isReduced) return widget.child;
if (_disabled) return widget.child;
// Gate the LeaderLayer to the focused card only: when not focused and not
// mid-fade, return the bare child (no OverlayPortal, no leader).
+25 -24
View File
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
import 'card_focus_scope.dart';
import 'dpad_navigator.dart';
import 'focus_glow_overlay.dart';
import 'focus_theme.dart';
@@ -110,14 +111,13 @@ class FocusableWrapper extends StatefulWidget {
/// Scale used for the focus animation.
final double focusScale;
/// Stroke alignment for the focus border.
final double focusBorderStrokeAlign;
/// Whether to draw a glow around the focused widget.
final bool useFocusGlow;
/// Whether to draw the focus border as a foreground decoration.
final bool useForegroundFocusDecoration;
/// Skip drawing the focus border here and expose the focus state through a
/// [CardFocusScope] instead, so the child places the border on the exact
/// rect it wants highlighted (e.g. MediaCard's poster image).
final bool delegateFocusBorder;
/// Whether descendants can receive focus.
/// Set to false when the child widget has its own Focus (e.g. buttons)
@@ -150,9 +150,8 @@ class FocusableWrapper extends StatefulWidget {
this.focusColor,
this.disableScale = false,
this.focusScale = FocusTheme.focusScale,
this.focusBorderStrokeAlign = BorderSide.strokeAlignInside,
this.useFocusGlow = false,
this.useForegroundFocusDecoration = false,
this.delegateFocusBorder = false,
this.descendantsAreFocusable = true,
});
@@ -471,16 +470,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
_animationController.duration = duration;
}
// Choose decoration based on useBackgroundFocus
final focusDecoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
borderStrokeAlign: widget.focusBorderStrokeAlign,
);
Widget result = Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
@@ -493,13 +482,25 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
final shouldScale = showFocus && !widget.disableScale;
// The glow (full-bleed cards) is drawn in an overlay above siblings so
// it stays symmetric; the in-card decoration only carries the border.
Widget card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: widget.useForegroundFocusDecoration ? null : focusDecoration,
foregroundDecoration: widget.useForegroundFocusDecoration ? focusDecoration : null,
child: widget.child,
);
Widget card;
if (widget.delegateFocusBorder) {
card = CardFocusScope(showFocus: showFocus, child: widget.child);
} else {
final focusDecoration = widget.useBackgroundFocus
? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius)
: FocusTheme.focusDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
);
card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: focusDecoration,
child: widget.child,
);
}
if (widget.useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,