/// UI-03: the expanding-ring-plus-glow-dot user-location marker from the Plan/Route /// Planning exports. Values (sizes, colours, the scale/opacity curve) are lifted from /// the exports' own `pulse-ring` keyframes, not guessed -- see /// `docs/design/stitch-export/screens/route-planning-dark.html`. library; import 'package:flutter/material.dart'; class PulsingLocationMarker extends StatefulWidget { const PulsingLocationMarker({super.key}); @override State createState() => _PulsingLocationMarkerState(); } class _PulsingLocationMarkerState extends State with SingleTickerProviderStateMixin { late final _controller = AnimationController( vsync: this, duration: const Duration(seconds: 2), ); @override void didChangeDependencies() { super.didChangeDependencies(); _syncWithReducedMotion(); } /// A static dot -- the ring never expands -- when the platform has reduced motion /// enabled, the same accessibility setting `prefers-reduced-motion` maps to on the /// web this component's design came from. void _syncWithReducedMotion() { final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false; if (reduceMotion) { _controller.stop(); } else if (!_controller.isAnimating) { _controller.repeat(); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return SizedBox( width: 40, height: 40, child: AnimatedBuilder( animation: _controller, builder: (context, child) { // The keyframes' own 0%/70%/100% easing: scale 0.8 -> 1.0 and opacity // 0.8 -> 0, both eased out rather than linear, so the ring visibly slows as // it fades rather than looking like it's cut off mid-motion. final t = Curves.easeOut.transform(_controller.value); final scale = 0.8 + (0.2 * t); final opacity = 0.8 * (1 - t); return Stack( alignment: Alignment.center, children: [ // The faint, static outer ring -- always present, pulse or no pulse. Container( width: 16, height: 16, decoration: BoxDecoration( color: colors.primary.withValues(alpha: 0.3), shape: BoxShape.circle, ), ), Transform.scale( scale: scale, child: Opacity( opacity: opacity, child: Container( width: 12, height: 12, decoration: BoxDecoration( color: colors.primary, shape: BoxShape.circle, ), ), ), ), Container( width: 8, height: 8, decoration: BoxDecoration( color: colors.primary, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: colors.primary.withValues(alpha: 0.5), blurRadius: 10, spreadRadius: 1, ), ], ), ), ], ); }, ), ); } }