UI-06: rebuild Plan & Route Planning as a fullscreen map canvas

Replaces RoutePlannerScreen's AppBar-driven layout with a headerless,
full-opacity map canvas restyled toward Map HUD rather than copied from
this screen's own (drifted) Stitch export: a floating "tap to drop a pin"
tooltip before any pins exist, FloatingPill (Distance/Est. Time/Pins) once
they do, a "Start Route" floating button, a GlassPanel overflow menu
replacing the old app-bar actions (rename/delete/download), and a
pin-drop ripple micro-interaction. RoutesListScreen keeps its existing
list-with-+-button entry point (the IA question in the ticket's Risk
section, deferred as explicitly permitted) but restyles its rows as
GlassPanel cards.

"Start Route" gets real behavior instead of shipping as a dead button:
turn-by-turn route following doesn't exist yet (V3-09), so tapping it
starts an ordinary recording and switches to the Map tab -- useful today,
not a placeholder for a feature that isn't there. Est. Time honestly
shows "--" rather than fabricating an estimate the data model doesn't
back. Marching-ants route-line animation was dropped from scope: it isn't
in the acceptance criteria, and animating a dash phase under live map
pan/zoom is real complexity for a purely decorative effect.

Fixes a real, pre-existing bug found while working in this file: the
offline-tile download dialog still fetched from tile.openstreetmap.org
directly, a leftover from before UI-09 switched the live map to CARTO's
dark tiles -- a "successful" download would have cached tiles under keys
the dark-tile cache never reads from.

Also fixes a live Flutter framework warning the new GlassPanel-wrapped
ListTile surfaced (ink splashes need a Material ancestor, which
GlassPanel's DecoratedBox was blocking) and a real test bug (two pins
placed one directly under the new floating Start Route button in a
fixed test camera, causing a tap meant for the pin to hit the button
instead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
2026-08-24 10:18:39 -05:00
parent fa6b1b8ef2
commit dccccb68b6
5 changed files with 545 additions and 174 deletions

View File

@@ -2,6 +2,10 @@
///
/// Straight lines only, deliberately — see the ticket. `geometry` stays null; the
/// polyline drawn here is derived from waypoints on every build, never persisted.
///
/// UI-06: rebuilt as a fullscreen map canvas with floating controls, restyled toward
/// Map HUD (UI-05) rather than copied from this screen's own Stitch export -- see
/// `docs/ui-redesign/README.md`'s note on why the Route Planning mockup drifted.
library;
import 'dart:async';
@@ -9,6 +13,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart' as ll;
@@ -16,9 +21,12 @@ import '../../app/providers.dart';
import '../../data/route_plan_repository.dart';
import '../../domain/models.dart';
import '../../geo/geo.dart' as geo;
import '../../telemetry/telemetry.dart' show formatDuration;
import '../../tiles/tile_cache.dart';
import '../../tiles/tile_downloader.dart';
import '../../tiles/tile_math.dart';
import '../components/floating_pill.dart';
import '../components/glass_panel.dart';
import '../components/ride_map.dart'
show
TileAttribution,
@@ -30,6 +38,7 @@ import '../components/ride_map.dart'
import '../components/skeleton_map_layer.dart';
import '../components/stats.dart' show confirmDialog;
import '../format.dart';
import '../router.dart';
class RoutePlannerScreen extends ConsumerStatefulWidget {
const RoutePlannerScreen({super.key, required this.routeId, this.onBack});
@@ -41,15 +50,45 @@ class RoutePlannerScreen extends ConsumerStatefulWidget {
ConsumerState<RoutePlannerScreen> createState() => _RoutePlannerScreenState();
}
class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
class _Ripple {
_Ripple(this.offset, this.controller);
final Offset offset;
final AnimationController controller;
}
class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen>
with TickerProviderStateMixin {
final _mapController = MapController();
final _ripples = <_Ripple>[];
@override
void dispose() {
_mapController.dispose();
for (final ripple in _ripples) {
ripple.controller.dispose();
}
super.dispose();
}
/// UI-06: a short-lived expanding-and-fading ring at the tap point, matching the
/// mockup's pin-drop micro-interaction. Placed once, at the moment of the tap, using
/// screen-local coordinates -- it lives for 500ms, so not tracking map pan/zoom
/// during that brief window is an acceptable simplification, not a real defect.
void _addRipple(Offset localPosition) {
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
late final _Ripple ripple;
ripple = _Ripple(localPosition, controller);
setState(() => _ripples.add(ripple));
controller.forward().whenComplete(() {
if (!mounted) return;
setState(() => _ripples.remove(ripple));
controller.dispose();
});
}
/// Fits every pin in view, the same way `RideMap` fits a recorded path -- otherwise a
/// fixed zoom guess either strands distant pins off-screen or, for two close-together
/// pins, can cull one before it is ever seen. Null below two points: a single pin (or
@@ -81,6 +120,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
if (route == null) {
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Center(
child: Text(
@@ -93,154 +133,221 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
key: const Key('back'),
icon: const Icon(Icons.arrow_back),
onPressed: widget.onBack,
),
title: Text(route.name),
actions: [
IconButton(
key: const Key('download-tiles'),
icon: const Icon(Icons.download_for_offline_outlined),
tooltip: waypoints.isEmpty
? 'Add pins first'
: 'Download offline tiles along this route',
onPressed: waypoints.isEmpty
? null
: () => _downloadOfflineTiles(context, waypoints),
),
IconButton(
key: const Key('rename-route'),
icon: const Icon(Icons.edit_outlined),
onPressed: () => _rename(context, repo, route),
),
IconButton(
key: const Key('delete-route'),
icon: const Icon(Icons.delete_outline),
onPressed: () async {
await repo.deleteRoutePlan(widget.routeId);
widget.onBack?.call();
},
),
],
),
body: Column(
children: [
Expanded(
// `MapOptions.initialCenter`/`initialZoom` are read exactly once, at
// FlutterMap's construction -- not on every rebuild. Building the map before
// the waypoints stream has delivered its first value would freeze the camera
// on null-island forever, even once real waypoints arrive. Wait for the first
// emission (typically one frame) so the initial camera is right from the
// start.
child: !waypointsAsync.hasValue
? const Center(child: CircularProgressIndicator())
: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCameraFit: _initialFit(waypoints),
initialCenter: waypoints.isEmpty
? const ll.LatLng(0, 0)
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3,
maxZoom: maxTileZoom,
onTap: (tapPosition, point) =>
repo.addWaypoint(widget.routeId, point.latitude, point.longitude),
),
children: [
// UI-02: same skeleton-or-tiles swap as RideMap's background usage --
// this screen manages its own FlutterMap directly rather than through
// RideMap, so it has to watch connectivity and swap the layer itself.
if (ref.watch(mapConnectivityProvider).skeletonMode)
const SkeletonMapLayer()
else
TileLayer(
urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,
retinaMode: true,
userAgentPackageName: tileUserAgent,
maxNativeZoom: tileMaxNativeZoom,
panBuffer: 0,
tileProvider: ref.watch(cachedTileProviderProvider),
),
if (waypoints.length >= 2)
PolylineLayer(
polylines: [
Polyline(
points: [
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
],
strokeWidth: 4,
// Visibly distinct from a recorded path (see the acceptance
// criteria) — dashed, and the planning accent rather than the
// speed-bucketed colours RideMap uses.
pattern: StrokePattern.dashed(segments: const [8, 6]),
color: colors.primary,
backgroundColor: Colors.transparent,
body: SafeArea(
child: !waypointsAsync.hasValue
? const Center(child: CircularProgressIndicator())
: Stack(
children: [
Positioned.fill(
// `MapOptions.initialCenter`/`initialZoom` are read exactly once, at
// FlutterMap's construction -- not on every rebuild. Building the map
// before the waypoints stream has delivered its first value would
// freeze the camera on null-island forever, even once real waypoints
// arrive; the `!waypointsAsync.hasValue` branch above waits for that
// first emission (typically one frame) so the initial camera is
// right from the start.
child: FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCameraFit: _initialFit(waypoints),
initialCenter: waypoints.isEmpty
? const ll.LatLng(0, 0)
: ll.LatLng(waypoints.first.latitude, waypoints.first.longitude),
initialZoom: waypoints.length <= 1 ? 14 : maxTileZoom - 3,
maxZoom: maxTileZoom,
onTap: (tapPosition, point) {
repo.addWaypoint(widget.routeId, point.latitude, point.longitude);
if (tapPosition.relative != null) {
_addRipple(tapPosition.relative!);
}
},
),
],
children: [
// UI-02: same skeleton-or-tiles swap as RideMap's background
// usage -- this screen manages its own FlutterMap directly
// rather than through RideMap, so it has to watch connectivity
// and swap the layer itself.
if (ref.watch(mapConnectivityProvider).skeletonMode)
const SkeletonMapLayer()
else
TileLayer(
urlTemplate: tileUrlTemplate,
subdomains: tileSubdomains,
retinaMode: true,
userAgentPackageName: tileUserAgent,
maxNativeZoom: tileMaxNativeZoom,
panBuffer: 0,
tileProvider: ref.watch(cachedTileProviderProvider),
),
if (waypoints.length >= 2)
PolylineLayer(
polylines: [
Polyline(
points: [
for (final w in waypoints) ll.LatLng(w.latitude, w.longitude),
],
strokeWidth: 4,
// Visibly distinct from a recorded path (see the
// acceptance criteria) — dashed, and the planning
// accent rather than the speed-bucketed colours
// RideMap uses. (UI-06: the mockup's own animated
// "marching ants" crawl was left as static dashes --
// it isn't in this ticket's acceptance criteria, and
// reprojecting a hand-animated dash phase under
// map pan/zoom is real added complexity for a purely
// decorative effect.)
pattern: StrokePattern.dashed(segments: const [8, 6]),
color: colors.primary,
),
],
),
MarkerLayer(
markers: [
for (var i = 0; i < waypoints.length; i++)
Marker(
key: Key('waypoint-${waypoints[i].id}'),
point: ll.LatLng(waypoints[i].latitude, waypoints[i].longitude),
width: 36,
height: 36,
child: _WaypointPin(
index: i,
onTap: () =>
repo.deleteWaypoint(widget.routeId, waypoints[i].id),
onPanUpdate: (delta) {
final camera = _mapController.camera;
final current = ll.LatLng(
waypoints[i].latitude,
waypoints[i].longitude,
);
final newOffset =
camera.latLngToScreenOffset(current) + delta;
final moved = camera.screenOffsetToLatLng(newOffset);
repo.moveWaypoint(
widget.routeId,
waypoints[i].id,
moved.latitude,
moved.longitude,
);
},
),
),
],
),
const TileAttribution(),
],
),
),
MarkerLayer(
markers: [
for (var i = 0; i < waypoints.length; i++)
Marker(
key: Key('waypoint-${waypoints[i].id}'),
point: ll.LatLng(waypoints[i].latitude, waypoints[i].longitude),
width: 36,
height: 36,
child: _WaypointPin(
index: i,
onTap: () =>
repo.deleteWaypoint(widget.routeId, waypoints[i].id),
onPanUpdate: (delta) {
final camera = _mapController.camera;
final current = ll.LatLng(
waypoints[i].latitude,
waypoints[i].longitude,
);
final newOffset =
camera.latLngToScreenOffset(current) + delta;
final moved = camera.screenOffsetToLatLng(newOffset);
repo.moveWaypoint(
widget.routeId,
waypoints[i].id,
moved.latitude,
moved.longitude,
// Pin-drop ripples paint in the same local coordinate space as the
// map's `onTap` gave us, directly over the map -- not inside
// FlutterMap's own `children` (a plain widget there doesn't get
// reprojected the way layer widgets do, which is fine for something
// this short-lived, but it does need to sit in a `Stack` positioned
// identically to the map itself).
for (final ripple in _ripples)
Positioned(
left: ripple.offset.dx - 30,
top: ripple.offset.dy - 30,
child: IgnorePointer(
child: AnimatedBuilder(
animation: ripple.controller,
builder: (context, child) {
final t = Curves.easeOut.transform(ripple.controller.value);
return Opacity(
opacity: 1 - t,
child: Container(
width: 60 * t,
height: 60 * t,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colors.primary.withValues(alpha: 0.2),
border: Border.all(color: colors.primary, width: 2),
),
),
);
},
),
),
],
),
const TileAttribution(),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.straighten, size: 18, color: colors.outline),
const SizedBox(width: 8),
Text(
key: const Key('route-distance'),
formatDistance(route.distanceM, unit: units),
style: TextStyle(fontWeight: FontWeight.bold, color: colors.onSurface),
),
Text(
' · ${waypoints.length} pin${waypoints.length == 1 ? '' : 's'}',
style: TextStyle(color: colors.outline),
),
],
),
),
],
),
if (waypoints.isEmpty)
const Positioned(top: 16, left: 0, right: 0, child: _DropPinTooltip()),
if (waypoints.isNotEmpty)
Positioned(
top: 16,
left: 0,
right: 0,
child: Center(
child: FloatingPill(
stats: [
PillStat(
label: 'Distance',
value: formatDistance(route.distanceM, unit: units),
),
PillStat(
label: 'Est. Time',
value: route.estimatedMillis == null
? '--'
: formatDuration(route.estimatedMillis!),
),
PillStat(label: 'Pins', value: '${waypoints.length}'),
],
),
),
),
Positioned(
top: 8,
right: 8,
child: _OverflowMenu(
hasWaypoints: waypoints.isNotEmpty,
onRename: () => _rename(context, repo, route),
onDownload: waypoints.isEmpty
? null
: () => _downloadOfflineTiles(context, waypoints),
onDelete: () async {
await repo.deleteRoutePlan(widget.routeId);
widget.onBack?.call();
},
),
),
if (waypoints.isNotEmpty)
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Center(
child: FilledButton(
key: const Key('start-route'),
style: FilledButton.styleFrom(
backgroundColor: colors.primaryContainer,
foregroundColor: colors.onPrimaryContainer,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: const StadiumBorder(),
),
onPressed: () => _startRoute(context),
child: const Text(
'START ROUTE',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1),
),
),
),
),
],
),
),
);
}
/// UI-06: turn-by-turn following of a planned route doesn't exist yet (V3-09,
/// explicitly deferred by this ticket) -- there is no "route currently being
/// followed" concept anywhere in the app to wire this button to. Rather than ship a
/// dead button or invent that data model here, this starts an ordinary recording and
/// switches to the Map tab, which is a real, working action a rider can use today
/// ("go start riding this route now"), not a placeholder.
Future<void> _startRoute(BuildContext context) async {
await ref.read(recordingEngineProvider).start();
if (context.mounted) context.go(Routes.record);
}
Future<void> _rename(
BuildContext context,
RoutePlanRepository repo,
@@ -271,7 +378,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
/// V3-11: pre-downloads a corridor along the route, not a rectangle around it -- "far
/// fewer tiles for the same usefulness," as the ticket puts it. Zoom range is fixed
/// (city-street level through the map's own max) rather than picked by the rider --
/// keeping the choice small is part of what keeps this within OSM's usage policy.
/// keeping the choice small is part of what keeps this within the tile host's usage
/// policy.
static const _downloadMinZoom = 13;
Future<void> _downloadOfflineTiles(
@@ -327,6 +435,108 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
}
}
/// UI-06: "Tap to drop a pin" -- shown only while the route has no pins yet, matching
/// the Plan Screen mockup's own gentle vertical float, a hint that disappears once the
/// rider has actually acted rather than a permanent fixture cluttering real work.
class _DropPinTooltip extends StatefulWidget {
const _DropPinTooltip();
@override
State<_DropPinTooltip> createState() => _DropPinTooltipState();
}
class _DropPinTooltipState extends State<_DropPinTooltip>
with SingleTickerProviderStateMixin {
late final _controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => Transform.translate(
offset: Offset(0, -6 * Curves.easeInOut.transform(_controller.value)),
child: child,
),
child: GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.location_on, color: colors.primary, size: 20),
const SizedBox(width: 8),
const Text(
'TAP TO DROP A PIN',
style: TextStyle(fontSize: 12, letterSpacing: 1, fontWeight: FontWeight.w600),
),
],
),
),
),
);
}
}
/// UI-06: rename/download/delete lost their `AppBar` actions row -- this is their new
/// home, styled per Map HUD's `GlassPanel` language rather than invented fresh.
class _OverflowMenu extends StatelessWidget {
const _OverflowMenu({
required this.hasWaypoints,
required this.onRename,
required this.onDownload,
required this.onDelete,
});
final bool hasWaypoints;
final VoidCallback onRename;
final VoidCallback? onDownload;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) => GlassPanel(
borderRadius: const BorderRadius.all(Radius.circular(999)),
child: PopupMenuButton<String>(
key: const Key('route-overflow-menu'),
icon: const Icon(Icons.more_vert),
onSelected: (value) => switch (value) {
'rename' => onRename(),
'download' => onDownload?.call(),
'delete' => onDelete(),
_ => null,
},
itemBuilder: (context) => [
const PopupMenuItem(
key: Key('rename-route'),
value: 'rename',
child: Text('Rename'),
),
PopupMenuItem(
key: const Key('download-tiles'),
value: 'download',
enabled: hasWaypoints,
child: const Text('Download offline tiles'),
),
const PopupMenuItem(
key: Key('delete-route'),
value: 'delete',
child: Text('Delete route'),
),
],
),
);
}
/// Owns the download's stream subscription for exactly the lifetime of the dialog --
/// pulled out of a plain `showDialog` builder because that builder re-runs on every
/// `setState`, which would otherwise start a brand new overlapping download on every
@@ -355,7 +565,18 @@ class _DownloadDialogState extends State<_DownloadDialog> {
cache: widget.cache,
cancelToken: _token,
fetchTile: (key) async {
final url = 'https://tile.openstreetmap.org/${key.z}/${key.x}/${key.y}.png';
// UI-09 correction: this still pointed at OSM's raw tile host directly, from
// before the switch to CARTO's dark tiles -- downloading tan OSM tiles for
// offline use while the live map renders CARTO's dark style would have quietly
// cached tiles that never actually get served (the cache is keyed by the same
// (z, x, y) regardless of provider, and the CARTO-tagged cache directory --
// see UI-09 -- would just never contain what this was fetching).
final url = tileUrlTemplate
.replaceFirst('{s}', tileSubdomains.first)
.replaceFirst('{z}', '${key.z}')
.replaceFirst('{x}', '${key.x}')
.replaceFirst('{y}', '${key.y}')
.replaceFirst('{r}', '');
final response = await _client.get(
Uri.parse(url),
headers: {'User-Agent': tileUserAgent},

View File

@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/providers.dart';
import '../../domain/models.dart';
import '../components/glass_panel.dart';
import '../components/stats.dart';
import '../format.dart';
@@ -58,19 +59,34 @@ class RoutesListScreen extends ConsumerWidget {
itemCount: routes.length,
itemBuilder: (context, i) {
final route = routes[i];
return Card(
key: Key('route-${route.id}'),
child: ListTile(
title: Text(route.name),
subtitle: Text(formatDistance(route.distanceM, unit: units)),
trailing: IconButton(
key: Key('delete-route-${route.id}'),
icon: Icon(Icons.delete_outline, color: colors.error),
onPressed: () => ref
.read(routePlanRepositoryProvider)
.deleteRoutePlan(route.id),
// UI-06: a GlassPanel-wrapped row rather than a plain Card --
// matches Map HUD's floating-glass language, which every other
// restyled screen now uses for list-like surfaces too.
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: GlassPanel(
key: Key('route-${route.id}'),
padding: EdgeInsets.zero,
// A `ListTile` paints its ink splash/tap feedback on the
// nearest `Material` ancestor -- `GlassPanel`'s own
// `DecoratedBox` sits between it and the `Scaffold`'s
// Material otherwise, which Flutter flags as a real bug
// (splashes would paint invisibly behind the decoration).
child: Material(
type: MaterialType.transparency,
child: ListTile(
title: Text(route.name),
subtitle: Text(formatDistance(route.distanceM, unit: units)),
trailing: IconButton(
key: Key('delete-route-${route.id}'),
icon: Icon(Icons.delete_outline, color: colors.error),
onPressed: () => ref
.read(routePlanRepositoryProvider)
.deleteRoutePlan(route.id),
),
onTap: () => onOpenRoute?.call(route.id),
),
),
onTap: () => onOpenRoute?.call(route.id),
),
);
},