fix a rare crash around blurring in newer versions of flutter - bit of a workaround

This commit is contained in:
stopflock
2026-09-04 19:37:37 -05:00
parent 00b9348b8f
commit cbdefa82fb
3 changed files with 156 additions and 75 deletions
+6 -3
View File
@@ -594,10 +594,13 @@ class _AddNodeSheetState extends State<AddNodeSheet> {
], ],
), ),
// Tutorial overlay - show only if tutorial should be shown and we're done checking // Tutorial overlay - always mounted once the initial check
if (!_isCheckingTutorial && _showTutorial) // completes (never conditionally inserted/removed - see
// PositioningTutorialOverlay's class doc for why), fades via
// its own `visible` flag instead.
if (!_isCheckingTutorial)
Positioned.fill( Positioned.fill(
child: PositioningTutorialOverlay(), child: PositioningTutorialOverlay(visible: _showTutorial),
), ),
], ],
); );
+6 -3
View File
@@ -627,10 +627,13 @@ class _EditNodeSheetState extends State<EditNodeSheet> {
], ],
), ),
// Tutorial overlay - show only if tutorial should be shown and we're done checking // Tutorial overlay - always mounted once the initial check
if (!_isCheckingTutorial && _showTutorial) // completes (never conditionally inserted/removed - see
// PositioningTutorialOverlay's class doc for why), fades via
// its own `visible` flag instead.
if (!_isCheckingTutorial)
Positioned.fill( Positioned.fill(
child: PositioningTutorialOverlay(), child: PositioningTutorialOverlay(visible: _showTutorial),
), ),
], ],
); );
+144 -69
View File
@@ -4,89 +4,164 @@ import 'package:flutter/material.dart';
import '../dev_config.dart'; import '../dev_config.dart';
import '../services/localization_service.dart'; import '../services/localization_service.dart';
/// Overlay that appears over add/edit node sheets to guide users through /// Overlay that appears over add/edit node sheets to guide users through
/// the positioning tutorial. Shows a blurred background with tutorial text. /// the positioning tutorial. Shows a blurred background with tutorial text.
class PositioningTutorialOverlay extends StatelessWidget { ///
/// Stability note: on Android's Impeller (Vulkan) renderer, BackdropFilter
/// snapshots the render pass's "back texture" mid-frame. If that snapshot
/// races against the surface being invalidated - which happens constantly
/// right where this overlay lives, since the enclosing bottom sheet resizes
/// on open/keyboard/rotation - Impeller can hit a null texture and hard-abort
/// the whole process natively (not a catchable Dart error). Two things keep
/// that race from being triggered here:
/// - [RepaintBoundary] isolates the filter onto its own stable composited
/// layer instead of sharing one with the actively-resizing sheet.
/// - The widget is always kept mounted by the parent sheet and toggles
/// visibility via a fade (see [visible]) rather than being abruptly
/// inserted/removed from the tree, which is believed to be the other half
/// of what triggers the crash.
class PositioningTutorialOverlay extends StatefulWidget {
const PositioningTutorialOverlay({ const PositioningTutorialOverlay({
super.key, super.key,
required this.visible,
this.onFadeOutComplete, this.onFadeOutComplete,
}); });
/// Called when the fade-out animation completes (if animated) /// Whether the tutorial should currently be showing. Toggling this fades
/// the overlay in/out - see class doc for why it's never removed from
/// the tree instead.
final bool visible;
/// Called when the fade-out animation completes (i.e. the overlay has
/// become fully transparent after [visible] turned false).
final VoidCallback? onFadeOutComplete; final VoidCallback? onFadeOutComplete;
@override
State<PositioningTutorialOverlay> createState() => _PositioningTutorialOverlayState();
}
class _PositioningTutorialOverlayState extends State<PositioningTutorialOverlay>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
value: widget.visible ? 1.0 : 0.0,
);
_controller.addStatusListener(_onStatusChanged);
}
void _onStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.dismissed) {
widget.onFadeOutComplete?.call();
}
}
@override
void didUpdateWidget(covariant PositioningTutorialOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) {
if (widget.visible) {
_controller.forward();
} else {
_controller.reverse();
}
}
}
@override
void dispose() {
_controller.removeStatusListener(_onStatusChanged);
_controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( // Ignore touches when faded out so the invisible-but-still-mounted
animation: LocalizationService.instance, // overlay doesn't block interaction with the sheet underneath.
builder: (context, child) { return IgnorePointer(
final locService = LocalizationService.instance; ignoring: !widget.visible,
child: FadeTransition(
return ClipRect( opacity: _controller,
child: BackdropFilter( child: AnimatedBuilder(
filter: ImageFilter.blur( animation: LocalizationService.instance,
sigmaX: kPositioningTutorialBlurSigma, builder: (context, child) {
sigmaY: kPositioningTutorialBlurSigma, final locService = LocalizationService.instance;
),
child: Container( return ClipRect(
decoration: BoxDecoration( child: RepaintBoundary(
color: Colors.black.withValues(alpha: 0.3), // Semi-transparent overlay child: BackdropFilter(
), filter: ImageFilter.blur(
child: Center( sigmaX: kPositioningTutorialBlurSigma,
child: Padding( sigmaY: kPositioningTutorialBlurSigma,
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), ),
child: Column( child: Container(
mainAxisSize: MainAxisSize.min, decoration: BoxDecoration(
children: [ color: Colors.black.withValues(alpha: 0.3), // Semi-transparent overlay
// Tutorial icon ),
Icon( child: Center(
Icons.pan_tool_outlined, child: Padding(
size: 48, padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
color: Colors.white, child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tutorial icon
Icon(
Icons.pan_tool_outlined,
size: 48,
color: Colors.white,
),
const SizedBox(height: 16),
// Tutorial title
Text(
locService.t('positioningTutorial.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Tutorial instructions
Text(
locService.t('positioningTutorial.instructions'),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Additional hint
Text(
locService.t('positioningTutorial.hint'),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
), ),
const SizedBox(height: 16), ),
// Tutorial title
Text(
locService.t('positioningTutorial.title'),
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Tutorial instructions
Text(
locService.t('positioningTutorial.instructions'),
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Additional hint
Text(
locService.t('positioningTutorial.hint'),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 14,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
), ),
), ),
), ),
), ),
), );
); },
}, ),
),
); );
} }
} }