UI-05: rebuild Record screen as the Map HUD (design north star)
Replaces the scrolling stats-card layout with the full-bleed Map HUD: UI-04's draggable/resizable HUD widgets float over UI-01's shared background map (full opacity on this tab), and the old Pause/Stop/Resume/ Discard buttons become full-bleed, icon-only segments matching the mockup's tertiary-container/error-container colors exactly. The idle state keeps its prior compact layout deliberately -- HUD widgets only appear once a ride exists, preserving the existing "a resting screen must not look like a ride going nowhere" guarantee rather than reinterpreting it. State-machine logic (ticker, wakelock, speed subscription, error handling) is untouched; every pre-existing record-screen test passed unchanged against the rebuilt screen. Added tests that actually tap Start/Pause/Stop and verify engine state changes, confirm HUD widgets render over the map rather than replacing it, and verify the mounted-mode speed digit's real contrast ratio against GlassPanel's translucent surface specifically (the ticket's own named risk). Corrects a UI-04 mistake found while implementing this ticket: the HUD's default four metrics were ordered Speed/Distance/Elapsed/Max Speed, a guess made before reading the actual Map HUD mockup HTML closely. The real fixed row is Speed/Avg Speed/Dist/Time -- reordered HudMetric to match and updated every test that asserted the old order. Adds PulsingLocationMarker (UI-03) to RideMap's live usage via a new showLocationMarker flag, and explicit tertiaryContainer/errorContainer tokens to ripprColors so the control bar matches the design system's literal values rather than an auto-derived tonal palette. Verified end-to-end on a real emulator: Start, Pause, Resume, and Stop all correctly drive the trip state machine with the full live map behind everything. A lengthy false alarm during this verification (taps appearing to do nothing) turned out to be a screenshot-scale mis-measurement on the verification side, not an app defect -- resolved by sampling pixel colors directly from the raw screenshot to find the control bar's true on-screen position. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
@@ -7,16 +7,20 @@
|
||||
library;
|
||||
|
||||
enum HudMetric {
|
||||
// The first four are, in order, the Stitch mockup's fixed row (Speed/Distance/
|
||||
// Elapsed/Max speed) -- `HudWidgetLayout.defaultFor` uses this ordering directly to
|
||||
// The first four are, in order, the actual Map HUD mockup's fixed telemetry row --
|
||||
// Speed / Avg Speed / Dist / Time, verified directly against
|
||||
// docs/design/stitch-export/screens/map-hud-dark.html's `#telemetry-container`
|
||||
// rather than assumed. `HudWidgetLayout.defaultFor` uses this ordering directly to
|
||||
// decide both default grid position and which metrics start visible, so this order
|
||||
// is load-bearing, not cosmetic.
|
||||
// is load-bearing, not cosmetic. (UI-05 correction: an earlier guess here, made
|
||||
// before that HTML had been read closely, wrongly ordered Distance/Elapsed/Max
|
||||
// Speed as the fixed four.)
|
||||
speed,
|
||||
avgSpeed,
|
||||
distance,
|
||||
elapsedTime,
|
||||
maxSpeed,
|
||||
movingTime,
|
||||
avgSpeed,
|
||||
elevationGain,
|
||||
pointsCaptured;
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ class HudWidgetLayout {
|
||||
y: 0.06 + row * (cellHeight + gap),
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
// The mockup's own fixed row is Speed/Distance/Elapsed/Max speed -- the first
|
||||
// The Map HUD mockup's own fixed row is Speed/Avg Speed/Dist/Time -- the first
|
||||
// four enum values are ordered to match, so only those start visible.
|
||||
visible: index < columns,
|
||||
);
|
||||
|
||||
@@ -95,6 +95,9 @@ class ShellScaffold extends ConsumerWidget {
|
||||
showEmptyLabel: false,
|
||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||
skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode,
|
||||
// UI-05: only on the Map tab -- a pulsing marker ticking away behind
|
||||
// a scrim on every other tab would be wasted animation no one sees.
|
||||
showLocationMarker: isMapTab,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../../domain/models.dart';
|
||||
import '../../geo/geo.dart' as geo;
|
||||
import '../../tiles/tile_config.dart';
|
||||
import '../theme.dart' show ripprRadiusLarge;
|
||||
import 'pulsing_location_marker.dart';
|
||||
import 'skeleton_map_layer.dart';
|
||||
|
||||
export '../../tiles/tile_config.dart';
|
||||
@@ -57,6 +58,7 @@ class RideMap extends StatefulWidget {
|
||||
this.fill = false,
|
||||
this.showEmptyLabel = true,
|
||||
this.skeletonMode = false,
|
||||
this.showLocationMarker = false,
|
||||
});
|
||||
|
||||
final List<TrackPoint> points;
|
||||
@@ -92,6 +94,11 @@ class RideMap extends StatefulWidget {
|
||||
/// following at all.
|
||||
final bool follow;
|
||||
|
||||
/// UI-05: draws a `PulsingLocationMarker` at the latest point -- only meaningful for
|
||||
/// the live shell background (paired with [follow]), never for a finished ride's
|
||||
/// static playback, where there is no "current position" to mark.
|
||||
final bool showLocationMarker;
|
||||
|
||||
@override
|
||||
State<RideMap> createState() => _RideMapState();
|
||||
}
|
||||
@@ -258,6 +265,21 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
tileProvider: widget.tileProvider,
|
||||
),
|
||||
PolylineLayer(polylines: polylines),
|
||||
if (widget.showLocationMarker && hasPoints)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
key: const Key('location-marker'),
|
||||
point: ll.LatLng(
|
||||
widget.points.last.latitude,
|
||||
widget.points.last.longitude,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: const PulsingLocationMarker(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const TileAttribution(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
/// Ported from `com.rippr.ui.record.RecordScreen` and `RecordViewModel`.
|
||||
///
|
||||
/// UI-05: this is the Map HUD -- the design north star of the whole redesign. No
|
||||
/// embedded map card, no stats `Card`, no header: the map is the shared full-opacity
|
||||
/// background this tab already provides (UI-01), and every other visual floats on top
|
||||
/// of it as a `GlassPanel`/HUD widget.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@@ -8,9 +13,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../app/providers.dart';
|
||||
import '../../domain/models.dart';
|
||||
import '../../hud/hud_metric.dart';
|
||||
import '../../recording/location_source.dart';
|
||||
import '../../recording/wakelock_controller.dart';
|
||||
import '../../telemetry/telemetry.dart';
|
||||
import '../components/glass_panel.dart';
|
||||
import '../components/hud_edit_overlay.dart';
|
||||
import '../components/stats.dart';
|
||||
import '../format.dart';
|
||||
import '../theme.dart';
|
||||
@@ -34,6 +42,14 @@ class RecordUiState {
|
||||
bool get isIdle => trip == null;
|
||||
bool get isRecording => trip?.state == TripState.recording;
|
||||
bool get isPaused => trip?.state == TripState.paused;
|
||||
|
||||
/// Distance-over-moving-time -- there is no stored average, only the pieces to
|
||||
/// derive one. Zero rather than a divide-by-zero NaN before the ride has moved at all.
|
||||
double get avgSpeedKmh {
|
||||
final t = trip;
|
||||
if (t == null || t.movingMillis <= 0) return 0;
|
||||
return (t.distanceM / 1000) / (t.movingMillis / 3600000);
|
||||
}
|
||||
}
|
||||
|
||||
class RecordScreen extends ConsumerStatefulWidget {
|
||||
@@ -140,6 +156,12 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 72dp normally, 96dp mounted -- one constant height regardless of how many
|
||||
/// segments the control bar has (one/two/three), so the HUD area above it is always
|
||||
/// the same size and a widget's saved fractional position never jumps between ride
|
||||
/// states.
|
||||
double _controlBarHeight(bool mountedMode) => mountedMode ? 96 : 72;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mountedMode = ref.watch(mountedModeProvider);
|
||||
@@ -165,136 +187,177 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
// UI-01: no header, no embedded map card -- the map is the shared background behind
|
||||
// this whole tab (`AppShell`), full opacity here. A quick mounted-mode toggle used to
|
||||
// live in a header row; that row is gone, and the toggle's only home for now is
|
||||
// Settings' existing switch (see the ticket's Outcome for why this is a deliberate,
|
||||
// temporary trade rather than an oversight -- UI-05 restyles this screen properly).
|
||||
return Theme(data: theme, child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
// Scrollable, but still centred when there is room.
|
||||
//
|
||||
// With a ride active the card grows to six stat rows, which overflows a short
|
||||
// screen. Compose clipped this silently; Flutter reports it, and a widget test
|
||||
// caught it immediately. Glove-sized 72dp controls make the content tall enough
|
||||
// that this is a real risk on small phones, not a theoretical one.
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Current speed, not max: a max figure only moves when you beat it, which
|
||||
// reads as a frozen screen while riding steadily. This is the 2.0.1 fix and
|
||||
// it must not regress.
|
||||
BigStat(
|
||||
key: const Key('speed'),
|
||||
label: 'SPEED',
|
||||
value: speedValue,
|
||||
unit: speedUnit,
|
||||
scale: mountedMode ? mountedTextScale : 1.0,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
if (ui.isIdle) ...[
|
||||
// A resting state, not a zeroed ride — otherwise the screen looks
|
||||
// like a recording that is going nowhere.
|
||||
const StatRow(label: 'Status', value: 'Ready'),
|
||||
const StatRow(
|
||||
label: 'Last ride',
|
||||
value: 'See Rides',
|
||||
),
|
||||
] else ...[
|
||||
StatRow(
|
||||
label: 'Distance',
|
||||
value: formatDistance(
|
||||
ui.trip!.distanceM,
|
||||
unit: units,
|
||||
),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elapsed',
|
||||
value: formatDuration(ui.elapsedMillis),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Moving time',
|
||||
value: formatDuration(ui.trip!.movingMillis),
|
||||
),
|
||||
StatRow(
|
||||
label: 'Max speed',
|
||||
value: formatSpeed(
|
||||
ui.trip!.maxSpeedKmh,
|
||||
unit: units,
|
||||
),
|
||||
reference: true,
|
||||
),
|
||||
StatRow(
|
||||
label: 'Points captured',
|
||||
value: '${ui.trip!.pointCount}',
|
||||
),
|
||||
StatRow(
|
||||
label: 'Elevation gain',
|
||||
value: formatElevation(
|
||||
ui.trip!.elevationGainM,
|
||||
unit: units,
|
||||
),
|
||||
),
|
||||
if (ui.isPaused)
|
||||
const StatRow(label: 'Status', value: 'Paused'),
|
||||
],
|
||||
if (ui.uploadError != null)
|
||||
StatRow(label: 'Problem', value: ui.uploadError!),
|
||||
],
|
||||
// Settings' existing switch.
|
||||
return Theme(
|
||||
data: theme,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (ui.isIdle)
|
||||
Center(
|
||||
child: GlassPanel(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Current speed, not max: a max figure only moves when you beat
|
||||
// it, which reads as a frozen screen while riding steadily.
|
||||
// This is the 2.0.1 fix and it must not regress.
|
||||
BigStat(
|
||||
key: const Key('speed'),
|
||||
label: 'SPEED',
|
||||
value: speedValue,
|
||||
unit: speedUnit,
|
||||
scale: mountedMode ? mountedTextScale : 1.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// A resting state, not a zeroed ride -- otherwise the screen
|
||||
// looks like a recording that is going nowhere.
|
||||
const StatRow(label: 'Status', value: 'Ready'),
|
||||
const StatRow(label: 'Last ride', value: 'See Rides'),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
_Controls(
|
||||
ui: ui,
|
||||
mounted: mountedMode,
|
||||
onStart: () => _guard(engine.start),
|
||||
onPause: () => _guard(engine.pause),
|
||||
onStop: () => _guard(() async => engine.stop()),
|
||||
onDiscard: () async {
|
||||
final confirmed = await confirmDialog(
|
||||
context,
|
||||
title: 'Discard this ride?',
|
||||
message:
|
||||
'${ui.trip?.pointCount ?? 0} points recorded over '
|
||||
'${formatDistance(ui.trip?.distanceM ?? 0)} will be '
|
||||
'permanently deleted.',
|
||||
confirmLabel: 'Discard',
|
||||
);
|
||||
if (confirmed) await _guard(engine.discard);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned.fill(
|
||||
bottom: _controlBarHeight(mountedMode),
|
||||
child: HudEditOverlay(
|
||||
metricBuilder: (context, metric) =>
|
||||
_HudMetricValue(metric: metric, ui: ui, units: units),
|
||||
),
|
||||
),
|
||||
if (ui.isPaused)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: GlassPanel(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(999)),
|
||||
child: const Text('Paused', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (ui.uploadError != null)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: GlassPanel(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(ui.uploadError!),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: _controlBarHeight(mountedMode),
|
||||
child: _ControlBar(
|
||||
ui: ui,
|
||||
onStart: () => _guard(engine.start),
|
||||
onPause: () => _guard(engine.pause),
|
||||
onStop: () => _guard(() async => engine.stop()),
|
||||
onDiscard: () async {
|
||||
final confirmed = await confirmDialog(
|
||||
context,
|
||||
title: 'Discard this ride?',
|
||||
message:
|
||||
'${ui.trip?.pointCount ?? 0} points recorded over '
|
||||
'${formatDistance(ui.trip?.distanceM ?? 0)} will be '
|
||||
'permanently deleted.',
|
||||
confirmLabel: 'Discard',
|
||||
);
|
||||
if (confirmed) await _guard(engine.discard);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of one HUD telemetry widget -- purely presentational, sourced from the
|
||||
/// same `RecordUiState`/units that powered the old fixed stats card. No new data
|
||||
/// plumbing, only new presentation, per the ticket's own Implementation section.
|
||||
class _HudMetricValue extends StatelessWidget {
|
||||
const _HudMetricValue({required this.metric, required this.ui, required this.units});
|
||||
|
||||
class _Controls extends StatelessWidget {
|
||||
const _Controls({
|
||||
final HudMetric metric;
|
||||
final RecordUiState ui;
|
||||
final UnitSystem units;
|
||||
|
||||
/// Matches the Map HUD mockup's own colour choices for its default four metrics
|
||||
/// exactly (Speed: primary, Avg Speed/Time: plain ink, Dist: secondary) rather than
|
||||
/// this app's usual reference-vs-live convention -- this HUD is a new visual context
|
||||
/// the mockup already specifies directly, colour by colour.
|
||||
Color _valueColor(ColorScheme colors) => switch (metric) {
|
||||
HudMetric.speed => colors.primary,
|
||||
HudMetric.distance => colors.secondary,
|
||||
_ => colors.onSurface,
|
||||
};
|
||||
|
||||
String get _value {
|
||||
final trip = ui.trip;
|
||||
return switch (metric) {
|
||||
HudMetric.speed => formatSpeedParts(ui.currentSpeedKmh, unit: units).$1,
|
||||
HudMetric.avgSpeed => formatSpeedParts(ui.avgSpeedKmh, unit: units).$1,
|
||||
HudMetric.distance => formatDistance(trip?.distanceM ?? 0, unit: units),
|
||||
HudMetric.elapsedTime => formatDuration(ui.elapsedMillis),
|
||||
HudMetric.maxSpeed => formatSpeed(trip?.maxSpeedKmh ?? 0, unit: units),
|
||||
HudMetric.movingTime => formatDuration(trip?.movingMillis ?? 0),
|
||||
HudMetric.elevationGain => formatElevation(trip?.elevationGainM ?? 0, unit: units),
|
||||
HudMetric.pointsCaptured => '${trip?.pointCount ?? 0}',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
metric.label.toUpperCase(),
|
||||
style: TextStyle(fontSize: 10, letterSpacing: 1, color: colors.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_value,
|
||||
style: monoDigits.copyWith(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _valueColor(colors),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The full-bleed, icon-only control bar directly above the bottom nav bar -- matches
|
||||
/// the Map HUD mockup's Pause/Stop segment exactly for the recording state; the idle
|
||||
/// and paused states aren't shown in that mockup, so they extend the same visual
|
||||
/// language (full-bleed, icon-only, colour communicates the action) rather than
|
||||
/// reverting to the old text-labelled buttons.
|
||||
class _ControlBar extends StatelessWidget {
|
||||
const _ControlBar({
|
||||
required this.ui,
|
||||
required this.onStart,
|
||||
required this.onPause,
|
||||
required this.onStop,
|
||||
required this.onDiscard,
|
||||
this.mounted = false,
|
||||
});
|
||||
|
||||
final RecordUiState ui;
|
||||
@@ -303,133 +366,143 @@ class _Controls extends StatelessWidget {
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onDiscard;
|
||||
|
||||
/// V3-05: 72dp works stopped; at speed, with gloves, it does not.
|
||||
final bool mounted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (ui.isIdle) {
|
||||
return _PrimaryButton(
|
||||
label: 'START RECORDING',
|
||||
onPressed: onStart,
|
||||
return _Segment(
|
||||
keyName: 'start',
|
||||
mounted: mounted,
|
||||
icon: Icons.play_arrow,
|
||||
label: 'START RECORDING',
|
||||
background: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
foreground: Theme.of(context).colorScheme.onTertiaryContainer,
|
||||
onTap: onStart,
|
||||
);
|
||||
}
|
||||
|
||||
if (ui.isRecording) {
|
||||
return Column(
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
_PrimaryButton(
|
||||
label: 'PAUSE', onPressed: onPause, keyName: 'pause', mounted: mounted),
|
||||
const SizedBox(height: 12),
|
||||
_SecondaryButton(
|
||||
label: 'STOP', onPressed: onStop, keyName: 'stop', mounted: mounted),
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
keyName: 'pause',
|
||||
icon: Icons.pause,
|
||||
background: colors.tertiaryContainer,
|
||||
foreground: colors.onTertiaryContainer,
|
||||
onTap: onPause,
|
||||
trailingBorder: true,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
keyName: 'stop',
|
||||
icon: Icons.stop,
|
||||
background: colors.errorContainer,
|
||||
foreground: colors.onErrorContainer,
|
||||
onTap: onStop,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Discard is offered only while paused. Putting a destructive action next to Pause
|
||||
// during a live ride invites a gloved mis-tap at speed.
|
||||
return Column(
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
children: [
|
||||
_PrimaryButton(
|
||||
label: 'RESUME', onPressed: onStart, keyName: 'resume', mounted: mounted),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SecondaryButton(
|
||||
label: 'STOP',
|
||||
onPressed: onStop,
|
||||
keyName: 'stop',
|
||||
mounted: mounted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SecondaryButton(
|
||||
label: 'DISCARD',
|
||||
onPressed: onDiscard,
|
||||
keyName: 'discard',
|
||||
destructive: true,
|
||||
mounted: mounted,
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _Segment(
|
||||
keyName: 'resume',
|
||||
icon: Icons.play_arrow,
|
||||
background: colors.tertiaryContainer,
|
||||
foreground: colors.onTertiaryContainer,
|
||||
onTap: onStart,
|
||||
trailingBorder: true,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
keyName: 'stop',
|
||||
icon: Icons.stop,
|
||||
background: colors.errorContainer,
|
||||
foreground: colors.onErrorContainer,
|
||||
onTap: onStop,
|
||||
trailingBorder: true,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
keyName: 'discard',
|
||||
icon: Icons.delete_outline,
|
||||
background: colors.surfaceContainerHighest,
|
||||
foreground: colors.error,
|
||||
onTap: onDiscard,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 72dp tall throughout — these get pressed with gloves on. Mounted mode goes bigger
|
||||
/// still (V3-05): stopped, 72dp is enough; at speed, with gloves, it is not.
|
||||
class _PrimaryButton extends StatelessWidget {
|
||||
const _PrimaryButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
class _Segment extends StatelessWidget {
|
||||
const _Segment({
|
||||
required this.keyName,
|
||||
this.mounted = false,
|
||||
required this.icon,
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.onTap,
|
||||
this.label,
|
||||
this.trailingBorder = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final String keyName;
|
||||
final bool mounted;
|
||||
final IconData icon;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final VoidCallback onTap;
|
||||
final String? label;
|
||||
final bool trailingBorder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
width: double.infinity,
|
||||
height: mounted ? 96 : 72,
|
||||
child: FilledButton(
|
||||
key: Key(keyName),
|
||||
onPressed: onPressed,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: mounted ? 26 : 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: background,
|
||||
// The canonical `Material` + `Ink` pairing: `Ink` paints the border directly onto
|
||||
// the `Material`'s own layer, which is what lets the `InkWell`'s splash paint
|
||||
// above it -- a separate `Container` ancestor wrapping `Material` would put the
|
||||
// border outside the ink layer entirely.
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
border: trailingBorder
|
||||
? Border(right: BorderSide(color: Colors.white.withValues(alpha: 0.1)))
|
||||
: null,
|
||||
),
|
||||
child: InkWell(
|
||||
key: Key(keyName),
|
||||
onTap: onTap,
|
||||
child: SizedBox.expand(
|
||||
child: Center(
|
||||
child: label == null
|
||||
? Icon(icon, color: foreground, size: 32)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: foreground),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label!,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SecondaryButton extends StatelessWidget {
|
||||
const _SecondaryButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
required this.keyName,
|
||||
this.destructive = false,
|
||||
this.mounted = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final String keyName;
|
||||
final bool destructive;
|
||||
final bool mounted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
height: mounted ? 76 : 56,
|
||||
child: OutlinedButton(
|
||||
key: Key(keyName),
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: destructive ? colors.error : colors.onSurface,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: mounted ? 20 : 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ const ripprColors = ColorScheme.dark(
|
||||
secondary: Color(0xFFAEC7F6),
|
||||
tertiary: _referenceOrange,
|
||||
onTertiary: Color(0xFF532200),
|
||||
// UI-05: explicit rather than left to `ColorScheme.dark`'s own M3 tonal-palette
|
||||
// derivation -- the Map HUD's Pause/Stop control bar needs to match the design
|
||||
// system's literal `tertiary-container`/`error-container` tokens exactly, not
|
||||
// whatever a generated tonal palette happens to compute from `tertiary`/`error` alone.
|
||||
tertiaryContainer: Color(0xFFE3711F),
|
||||
onTertiaryContainer: Color(0xFF481D00),
|
||||
surface: _surfaceMid,
|
||||
onSurface: _ink,
|
||||
surfaceContainerHighest: Color(0xFF353534),
|
||||
@@ -57,6 +63,8 @@ const ripprColors = ColorScheme.dark(
|
||||
outlineVariant: Color(0xFF414753),
|
||||
error: _danger,
|
||||
onError: Color(0xFF690005),
|
||||
errorContainer: Color(0xFF93000A),
|
||||
onErrorContainer: Color(0xFFFFDAD6),
|
||||
);
|
||||
|
||||
/// The near-black ground the whole app sits on.
|
||||
|
||||
Reference in New Issue
Block a user