Files
rippr/lib/src/ui/components/pulsing_location_marker.dart
uhryniuk 24f4e17e81 UI-03: shared floating-glass component kit
Adds GlassPanel (BackdropFilter + translucent fill + subtle border, values
lifted directly from the Stitch exports' own bg-surface/90 backdrop-blur-xl
border-outline-variant/30 treatment), FloatingPill (a GlassPanel shaped as
a capsule around an arbitrary number of labeled stat columns with
dividers), and PulsingLocationMarker (expanding-ring-plus-glow-dot,
matching the exports' pulse-ring keyframes exactly, with a static-dot
fallback when reduced motion is requested). Pure presentation -- every
UI-04/05/06/07 screen composes these rather than reimplementing blur/
border styling per screen.

Adds outlineVariant to ripprColors, needed for the panel border and pill
dividers and not previously wired up by UI-08.

Verified with a throwaway preview entry point (deleted after use, no
consuming screen exists yet) plus 8 new widget tests covering blur/
translucency/border structure, contrast in both themes, arbitrary stat
counts, and the pulsing marker's animation lifecycle and reduced-motion
fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-23 20:22:19 -05:00

110 lines
3.4 KiB
Dart

/// 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<PulsingLocationMarker> createState() => _PulsingLocationMarkerState();
}
class _PulsingLocationMarkerState extends State<PulsingLocationMarker>
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,
),
],
),
),
],
);
},
),
);
}
}