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 /// ABOVE sibling cards on all four sides. /// /// The glow is an outward blur ([FocusTheme.focusGlowShadows]). When drawn /// in-tree behind a packed rail/grid it is occluded by later-painted neighbours /// on the trailing edges and only escapes on the leading (left) edge — producing /// a one-sided halo (issue #1231). Lifting it into an [OverlayPortal] that /// follows the card via [LayerLink] makes it render above every sibling, so the /// glow is symmetric on all sides. The crisp focus border stays in-card; only /// the glow moves to the overlay. /// /// Only mounts the overlay/leader while the card is focused (or fading out), so /// there is at most one [LeaderLayer] on screen regardless of how many cards a /// grid builds. class FocusGlowOverlay extends StatefulWidget { const FocusGlowOverlay({ super.key, required this.isFocused, required this.borderRadius, required this.color, required this.child, this.glowSize, }); /// Whether the wrapped card currently shows focus. Drives the glow. final bool isFocused; /// Border radius of the card, used for the glow's rounded rect. final double borderRadius; /// Glow colour, resolved from the card's theme at the call site (the overlay /// builds in the root Overlay's context, which may not carry a nested theme). final Color color; /// Explicit card size. When null, falls back to [LayerLink.leaderSize] (one /// frame late on first show, hidden under the opacity-0 fade-in). final Size? glowSize; final Widget child; @override State createState() => _FocusGlowOverlayState(); } class _FocusGlowOverlayState extends State { final OverlayPortalController _controller = OverlayPortalController(); final LayerLink _link = LayerLink(); /// Drives the [AnimatedOpacity] target. Set false on focus loss so the glow /// 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 && !_disabled) { _visible = true; _controller.show(); } } @override void didUpdateWidget(FocusGlowOverlay oldWidget) { super.didUpdateWidget(oldWidget); if (_disabled) return; if (widget.isFocused == oldWidget.isFocused) return; if (widget.isFocused) { _controller.show(); // Start hidden, then fade in next frame. _visible = false; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && widget.isFocused) setState(() => _visible = true); }); } else { // Fade out; _handleFadeEnd hides the portal once opacity reaches 0. setState(() => _visible = false); } } void _handleFadeEnd() { if (!_visible && mounted && _controller.isShowing) { _controller.hide(); setState(() {}); // drop the OverlayPortal/leader from the tree } } @override Widget build(BuildContext context) { 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). if (!widget.isFocused && !_controller.isShowing) { return widget.child; } final duration = FocusTheme.getAnimationDuration(context); return OverlayPortal( controller: _controller, overlayChildBuilder: (overlayContext) { final size = widget.glowSize ?? _link.leaderSize; final extent = FocusTheme.focusGlowExtent; return CompositedTransformFollower( link: _link, targetAnchor: Alignment.topLeft, followerAnchor: Alignment.topLeft, offset: Offset(-extent, -extent), child: IgnorePointer( child: AnimatedOpacity( opacity: _visible ? 1.0 : 0.0, duration: duration, curve: Curves.easeOutCubic, onEnd: _handleFadeEnd, child: size == null ? const SizedBox.shrink() : CustomPaint( size: Size(size.width + extent * 2, size.height + extent * 2), painter: _FocusGlowPainter( rect: Offset(extent, extent) & size, borderRadius: widget.borderRadius, shadows: FocusTheme.focusGlowShadows(widget.color), ), ), ), ), ); }, child: CompositedTransformTarget(link: _link, child: widget.child), ); } } /// Paints [shadows] around [rect] with the rect interior clipped out, so only /// the outer glow shows — matching the original look where the opaque card hid /// the inner part of the shadow. class _FocusGlowPainter extends CustomPainter { const _FocusGlowPainter({required this.rect, required this.borderRadius, required this.shadows}); final Rect rect; final double borderRadius; final List shadows; @override void paint(Canvas canvas, Size size) { final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius)); final clip = Path.combine(PathOperation.difference, Path()..addRect(Offset.zero & size), Path()..addRRect(rrect)); canvas.save(); canvas.clipPath(clip); for (final shadow in shadows) { canvas.drawRRect(rrect.shift(shadow.offset).inflate(shadow.spreadRadius), shadow.toPaint()); } canvas.restore(); } @override bool shouldRepaint(_FocusGlowPainter oldDelegate) { return oldDelegate.rect != rect || oldDelegate.borderRadius != borderRadius || !listEquals(oldDelegate.shadows, shadows); } }