Files
plezy/lib/focus/focus_glow_overlay.dart

176 lines
6.2 KiB
Dart

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<FocusGlowOverlay> createState() => _FocusGlowOverlayState();
}
class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
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<BoxShadow> 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);
}
}