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
This commit is contained in:
99
lib/src/ui/components/floating_pill.dart
Normal file
99
lib/src/ui/components/floating_pill.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
/// UI-03: the horizontally-centred, fully-rounded stat summary from the Route Planning
|
||||
/// export's Distance/Est. Time/Pins header -- a `GlassPanel` shaped as a capsule, laid
|
||||
/// out with vertical dividers between however many stats it's given (not hardcoded to
|
||||
/// three, so a screen ticket can use it for two stats or five without a second widget).
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'glass_panel.dart';
|
||||
|
||||
class PillStat {
|
||||
const PillStat({required this.label, required this.value, this.unit});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
/// Rendered smaller and after [value], matching the export's `12.4<span
|
||||
/// class="text-sm">km</span>` treatment -- a unit is not part of the number itself.
|
||||
final String? unit;
|
||||
}
|
||||
|
||||
class FloatingPill extends StatelessWidget {
|
||||
const FloatingPill({super.key, required this.stats});
|
||||
|
||||
final List<PillStat> stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return GlassPanel(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(999)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < stats.length; i++) ...[
|
||||
if (i > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
_StatColumn(stat: stats[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatColumn extends StatelessWidget {
|
||||
const _StatColumn({required this.stat});
|
||||
|
||||
final PillStat stat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
stat.label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
letterSpacing: 1.5,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
stat.value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
if (stat.unit != null) ...[
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
stat.unit!,
|
||||
style: TextStyle(fontSize: 13, color: colors.primary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/src/ui/components/glass_panel.dart
Normal file
62
lib/src/ui/components/glass_panel.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
/// UI-03: the one blurred-glass surface every floating card, tooltip, and control in
|
||||
/// the redesign is built on, so `backdrop-blur`/translucency/border styling is written
|
||||
/// once here rather than reinvented per screen. Pure presentation -- no data-fetching,
|
||||
/// no business logic.
|
||||
///
|
||||
/// Values are lifted directly from the Stitch exports' own glass treatment
|
||||
/// (`bg-surface/90 backdrop-blur-xl border border-outline-variant/30`), not guessed:
|
||||
/// see `docs/design/stitch-export/screens/route-planning-dark.html`.
|
||||
library;
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart' show ripprRadiusLarge;
|
||||
|
||||
class GlassPanel extends StatelessWidget {
|
||||
const GlassPanel({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = const BorderRadius.all(Radius.circular(ripprRadiusLarge)),
|
||||
this.padding,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final BorderRadiusGeometry borderRadius;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Tailwind's `backdrop-blur-xl`. Strong enough that whatever is behind the panel
|
||||
/// (the live map, in every real usage) reads as texture, not detail -- the panel's
|
||||
/// own content is what the eye is meant to land on.
|
||||
static const double blurSigma = 24.0;
|
||||
|
||||
/// `bg-surface/90` -- almost opaque. Enough blur shows through to feel like glass
|
||||
/// over the map rather than a solid card, without ever risking the panel's own text
|
||||
/// losing contrast against whatever happens to be moving underneath it.
|
||||
static const double fillOpacity = 0.9;
|
||||
|
||||
/// `border-outline-variant/30` -- a barely-there seam, not a hard edge.
|
||||
static const double borderOpacity = 0.3;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface.withValues(alpha: fillOpacity),
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: borderOpacity),
|
||||
),
|
||||
),
|
||||
child: padding == null ? child : Padding(padding: padding!, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/src/ui/components/pulsing_location_marker.dart
Normal file
109
lib/src/ui/components/pulsing_location_marker.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
/// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ const ripprColors = ColorScheme.dark(
|
||||
surfaceContainerHighest: Color(0xFF353534),
|
||||
onSurfaceVariant: Color(0xFFC1C6D5),
|
||||
outline: _outline,
|
||||
// UI-03: the design system's own `outline-variant` token -- a subtler line than
|
||||
// `outline` itself, for a `GlassPanel`'s 1px border and a `FloatingPill`'s dividers,
|
||||
// where a full-strength outline colour would read as a hard edge rather than a
|
||||
// barely-there seam between tonal layers.
|
||||
outlineVariant: Color(0xFF414753),
|
||||
error: _danger,
|
||||
onError: Color(0xFF690005),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user