feat(train): a workout's page as Strava's, maps readable and in the app's language, routes opening near the person (ADR 0030, D63)

- A workout with a track: its map to the foot of the screen under a sheet
  with its name; once the sheet settles, the map fits the track anew, eased,
  into what is left free; pulled up, the round buttons over the map fold
  into an app bar whose chevron lowers the sheet again. From expanded
  windows on, the map beside the rest. The page stays in Home but opens
  over the shell, without the floating bar.
- What the map's source sees moves behind a question mark.
- The map's credit on the app's theme: flutter_map's, built on
  flutter/material, never saw it (material_ui's Theme is a class of its
  own) and drew the theme's text on white; map_tiles read that Theme too,
  and so never chose the instance's dark map (D50).
- Places named in the app's language (name:<language>) where OpenFreeMap's
  styles ask for the English name.
- The route planner's hint behind a question mark; a new route's map opens
  at zoom 14 where the phone was last known to be, else where the newest
  workout with a track started; its details beside the map from expanded
  windows on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 22:02:06 +01:00
commit 3e90753b08
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 1518 additions and 358 deletions

View file

@ -109,6 +109,7 @@ class MainActivity : FlutterActivity() {
"show" -> result.success(RecorderService.show(call.arguments as String).let { null })
"isRecording" -> result.success(RecorderService.isRecording())
"position" -> position(result)
"lastPosition" -> result.success(lastPosition())
else -> result.notImplemented()
}
}
@ -279,6 +280,22 @@ class MainActivity : FlutterActivity() {
}
}
/** Where the phone was last known to be by any provider, the newest; none without the
* location permission, which this never asks for (ADR 0030, D63: a new route's map opens
* there at once). */
private fun lastPosition(): Map<String, Double>? {
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
) {
return null
}
val manager = getSystemService(LocationManager::class.java)
val newest = manager.getProviders(true)
.mapNotNull { manager.getLastKnownLocation(it) }
.maxByOrNull { it.time }
return newest?.let { mapOf("lat" to it.latitude, "lon" to it.longitude) }
}
private companion object {
const val RECORDER_REQUEST = 1
const val FRESH_MS = 120_000L

View file

@ -94,6 +94,10 @@
"workoutMapOwn": "Karte von deiner Instanz, {name}, aus OpenStreetMap: sie speichert nichts vom gezeigten Ausschnitt.",
"workoutMapSourceAbout": "Was {name} speichert",
"workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.",
"workoutMapAbout": "Über die Karte",
"workoutShowMap": "Karte zeigen",
"mapCredits": "Kartennachweise",
"routeHelpTitle": "Eine Route planen",
"workoutNotFound": "Dieses Training gibt es nicht mehr.",
"recordHeartRate": "Herzfrequenz",

View file

@ -120,6 +120,14 @@
"workoutMapSourceAbout": "What {name} keeps",
"@workoutMapSourceAbout": {"description": "A link to the map source's page about what it keeps.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}},
"workoutMapUnavailable": "The map can't be loaded now.",
"workoutMapAbout": "About the map",
"@workoutMapAbout": {"description": "Title of the explanation behind the question mark on a workout's page (D63): what the map's source sees."},
"workoutShowMap": "Show the map",
"@workoutShowMap": {"description": "Tooltip of the chevron that lowers a workout's sheet, the page pulled up, so that the map shows again (D63)."},
"mapCredits": "Map credits",
"@mapCredits": {"description": "Tooltip of the button over a map that shows whose map and data it is."},
"routeHelpTitle": "Planning a route",
"@routeHelpTitle": {"description": "Title of the explanation behind the question mark over the route planner's map (D63)."},
"workoutNotFound": "This workout is gone.",
"recordHeartRate": "Heart rate",

View file

@ -622,6 +622,30 @@ abstract class AppLocalizations {
/// **'The map can\'t be loaded now.'**
String get workoutMapUnavailable;
/// Title of the explanation behind the question mark on a workout's page (D63): what the map's source sees.
///
/// In en, this message translates to:
/// **'About the map'**
String get workoutMapAbout;
/// Tooltip of the chevron that lowers a workout's sheet, the page pulled up, so that the map shows again (D63).
///
/// In en, this message translates to:
/// **'Show the map'**
String get workoutShowMap;
/// Tooltip of the button over a map that shows whose map and data it is.
///
/// In en, this message translates to:
/// **'Map credits'**
String get mapCredits;
/// Title of the explanation behind the question mark over the route planner's map (D63).
///
/// In en, this message translates to:
/// **'Planning a route'**
String get routeHelpTitle;
/// No description provided for @workoutNotFound.
///
/// In en, this message translates to:

View file

@ -344,6 +344,18 @@ class AppLocalizationsDe extends AppLocalizations {
String get workoutMapUnavailable =>
'Die Karte kann gerade nicht geladen werden.';
@override
String get workoutMapAbout => 'Über die Karte';
@override
String get workoutShowMap => 'Karte zeigen';
@override
String get mapCredits => 'Kartennachweise';
@override
String get routeHelpTitle => 'Eine Route planen';
@override
String get workoutNotFound => 'Dieses Training gibt es nicht mehr.';

View file

@ -341,6 +341,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workoutMapUnavailable => 'The map can\'t be loaded now.';
@override
String get workoutMapAbout => 'About the map';
@override
String get workoutShowMap => 'Show the map';
@override
String get mapCredits => 'Map credits';
@override
String get routeHelpTitle => 'Planning a route';
@override
String get workoutNotFound => 'This workout is gone.';

View file

@ -1,8 +1,8 @@
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// The app's locations (ADR 0030, D60): the five tabs, a workout's page in
/// Home and a day's in Profile, and the editors and the recording over the
/// tabs, and Settings.
/// The app's locations (ADR 0030, D60): the five tabs, a day's page in
/// Profile, and a workout's page (D63), the editors and the recording over
/// the tabs, and Settings.
abstract final class TrainPaths() {
/// Home, where the app opens: the workouts.
static const home = '/';
@ -19,7 +19,7 @@ abstract final class TrainPaths() {
/// Profile: the days.
static const profile = '/profile';
/// A workout's page, in Home.
/// A workout's page, in Home, over the tabs (D63).
static String workout(String id) => '/workouts/$id';
/// The page of the day [date], in Profile, by the band's name for it.

View file

@ -16,14 +16,19 @@ import 'package:halkyon_train_flutter/src/tabs/profile_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/train_shell.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The router of one person's app (ADR 0030, D60), as Learn's: the five tabs
/// in a shell, each with its own stack of screens, and the editors and the
/// recording over them. Every route is built inside the person's scope, so
/// in a shell, each with its own stack of screens, and the editors, the
/// recording and a workout's page, in Home, over them (D63). Every route is built inside the person's scope, so
/// screens read their repositories from `PersonScope`. A route or plan is
/// handed to its editor as it is (`extra`): a new one exists nowhere else
/// yet.
GoRouter buildTrainRouter() => GoRouter(
GoRouter buildTrainRouter() => _router(GlobalKey());
/// The router, its navigator over the shell [root].
GoRouter _router(GlobalKey<NavigatorState> root) => GoRouter(
navigatorKey: root,
routes: [
StatefulShellRoute(
builder: (context, state, shell) => TrainShell(shell: shell),
@ -38,8 +43,11 @@ GoRouter buildTrainRouter() => GoRouter(
path: TrainPaths.home,
builder: (context, state) => const HomeTab(),
routes: [
// In Home, so that back leads there, but over the shell,
// its map to the foot of the screen (D63).
GoRoute(
path: 'workouts/:id',
parentNavigatorKey: root,
builder: (context, state) =>
workoutPage(context, state.pathParameters['id']!),
),

View file

@ -21,3 +21,39 @@ Map<String, String>? tileHeadersOf(MapSource source) =>
final key? => {'authorization': 'Bearer $key'},
null => null,
};
/// [style] (a MapLibre style's JSON) naming places in [language] (ADR 0030,
/// D63): where its labels ask for the English name (`name_en`, as
/// OpenFreeMap's styles do), the name in [language] (OpenMapTiles'
/// `name:<language>`) instead, the name on the spot where there is none, as
/// before. English, and a style asking for no English name (the instance's
/// own, named in its language by its server, D50), stay as they are.
Map<String, Object?> labelledIn(Map<String, Object?> style, String language) {
if (language == 'en') {
return style;
}
final name = 'name:$language';
Object? relabel(Object? value) => switch (value) {
'name_en' => name,
final String text => text.replaceAll('{name_en}', '{$name}'),
final List<Object?> list => [for (final item in list) relabel(item)],
final Map<String, Object?> map => {
for (final MapEntry(:key, value: item) in map.entries) key: relabel(item),
},
_ => value,
};
return {
...style,
if (style['layers'] case final List<Object?> layers)
'layers': [
for (final layer in layers)
switch (layer) {
{'layout': final Map<String, Object?> layout} => {
...layer as Map<String, Object?>,
'layout': relabel(layout),
},
_ => layer,
},
],
};
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/painting.dart';
import 'package:latlong2/latlong.dart';
/// How high the round buttons over a workout's map stand (ADR 0030, D63):
/// the app bar's height, which they turn into.
const trackButtonsHeight = 56.0;
/// The margin kept around the track where it is fitted.
const trackFitMargin = 32.0;
/// How much of the map a track is fitted into at least, however far the
/// sheet is pulled up.
const minimumTrackRoom = 96.0;
/// From how far up the sheet is pulled (of the page's height) the round
/// buttons over the map fold into the app bar.
const trackBarFrom = 0.85;
/// The room left around the track on a map [view] large (D63): below the
/// status bar ([top]) and the buttons, above the sheet covering [covered]
/// of its foot, with [trackFitMargin] around; never less than
/// [minimumTrackRoom] high, the sheet's side giving way.
EdgeInsets trackFitPadding({
required Size view,
required double top,
required double covered,
}) {
final above = top + trackButtonsHeight + trackFitMargin;
final below = covered + trackFitMargin;
final most = view.height - above - minimumTrackRoom;
return EdgeInsets.fromLTRB(
trackFitMargin,
above,
trackFitMargin,
below.clamp(0, most < 0 ? 0 : most),
);
}
/// A camera: where it looks and how close.
typedef CameraAt = ({LatLng center, double zoom});
/// The camera [t] (0 to 1) of the way from [from] to [to], as the map moves
/// to fit the track anew once the sheet settles.
CameraAt cameraBetween(CameraAt from, CameraAt to, double t) => switch (t) {
<= 0 => from,
>= 1 => to,
_ => (
center: LatLng(
from.center.latitude + (to.center.latitude - from.center.latitude) * t,
from.center.longitude + (to.center.longitude - from.center.longitude) * t,
),
zoom: from.zoom + (to.zoom - from.zoom) * t,
),
};
/// How far the app bar has come in (0 to 1) with the sheet pulled up to
/// [extent] of the page: none below [trackBarFrom], all at the top.
double barShown(double extent) =>
((extent - trackBarFrom) / (1 - trackBarFrom)).clamp(0, 1).toDouble();

View file

@ -1,9 +1,12 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_style.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:vector_map_tiles/vector_map_tiles.dart';
import 'package:vector_tile_renderer/vector_tile_renderer.dart'
show SpriteIndexReader, ThemeReader;
@ -52,7 +55,8 @@ Future<Style> _keptStyle(
final sprite = style.spriteIndex;
final image = style.spriteImage;
return Style(
theme: ThemeReader().read(style.json),
// Its places named in the app's language (D63).
theme: ThemeReader().read(labelledIn(style.json, language)),
providers: TileProviders({
for (final MapEntry(:key, :value) in style.sources.entries)
key: _KeptFirst(maps, key, value, tileHeadersOf(source)),
@ -137,3 +141,32 @@ class _VectorTilesState() extends State<_VectorTiles> {
},
);
}
/// [source]'s credit over a map, folded into a button, as it is longer than
/// a phone is wide; open, on the theme's surface in the theme's colours,
/// readable in either (ADR 0030, D63: flutter_map's own, built on
/// `flutter/material`, never sees the app's theme, which is `material_ui`'s,
/// and put the theme's text on the fallback theme's white).
Widget mapAttribution(BuildContext context, MapSource source) {
final scheme = Theme.of(context).colorScheme;
final text = TextStyle(color: scheme.onSurface);
return DefaultTextStyle.merge(
style: text,
child: RichAttributionWidget(
popupBackgroundColor: scheme.surfaceContainerHigh,
openButton: (context, open) => IconButton.filledTonal(
tooltip: AppLocalizations.of(context).mapCredits,
icon: const Icon(LucideIcons.info),
onPressed: open,
),
closeButton: (context, close) => IconButton(
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
icon: Icon(LucideIcons.x, color: scheme.onSurface),
onPressed: close,
),
attributions: [
TextSourceAttribution(source.attribution, textStyle: text),
],
),
);
}

View file

@ -98,6 +98,13 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
_ => null,
};
@override
Future<({double lat, double lon})?> lastPosition() async =>
switch (await _channel.invokeMapMethod<String, double>('lastPosition')) {
{'lat': final lat, 'lon': final lon} => (lat: lat, lon: lon),
_ => null,
};
@override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();

View file

@ -154,6 +154,12 @@ abstract interface class RecorderPlatform() {
/// the location permission ([ask] first) or a fix within 15 s.
Future<({double lat, double lon})?> position();
/// Where the phone was last known to be, by any of its ways of telling,
/// at once and however long ago; null without the location permission
/// (never asked for here) or where none is known (ADR 0030, D63: a new
/// route's map opens there while [position] waits for a fix).
Future<({double lat, double lon})?> lastPosition();
/// Every line of the file written so far, then each new one as it is
/// written, while a recording runs.
Stream<String> lines();

View file

@ -586,9 +586,7 @@ class _RecordingMapState() extends State<_RecordingMap> {
],
),
if (snapshot.data case final source?)
RichAttributionWidget(
attributions: [TextSourceAttribution(source.attribution)],
),
mapAttribution(context, source),
],
),
if (live.route case final route?)

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:go_router/go_router.dart';
@ -21,6 +23,8 @@ import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
show ChartPoint;
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_start.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
@ -66,10 +70,29 @@ Widget routePage(BuildContext context, RouteContent route, {String? id}) {
create: (context) => PlaceSearchCubit(train.placeSearch)..open(),
),
],
child: RouteScreen(maps: train.maps.source()),
child: RouteScreen(
maps: train.maps.source(),
// Only a new route's map opens near the person; a stored one fits it.
near: route.waypoints.isEmpty
? _near(platform, train.workouts)
: Future.value(),
),
);
}
/// Where a new route's map opens at once (ADR 0030, D63): where the phone
/// was last known to be, else where the newest workout with a track
/// started; null where neither is known, the map then showing the region.
Future<LatLng?> _near(
RecorderPlatform? platform,
WorkoutRepository workouts,
) async {
if (await platform?.lastPosition() case (:final lat, :final lon)) {
return LatLng(lat, lon);
}
return await lastWorkoutStart(workouts);
}
/// Where the phone is, the location permission asked for first.
Future<LatLng?> _here(RecorderPlatform platform) async {
if (!(await platform.ask({
@ -89,8 +112,11 @@ Future<LatLng?> _here(RecorderPlatform platform) async {
/// under it the route's name, what it is for and its profile, and the way
/// planned: its distance, climb and descent, its elevation and its
/// surfaces. Saved with the button, deleted after asking.
class const RouteScreen({required final Future<MapSource?> maps, super.key})
extends StatelessWidget {
class const RouteScreen({
required final Future<MapSource?> maps,
final Future<LatLng?>? near,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -130,24 +156,37 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
flex: 3,
child: FutureBuilder(
future: maps,
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(:final MapSource data) => _RouteMap(
source: data,
),
AsyncSnapshot(connectionState: ConnectionState.done) =>
Center(child: Text(l10n.workoutMapUnavailable)),
_ => const Center(child: AppLoadingIndicator()),
},
),
),
const Expanded(flex: 2, child: _Details()),
],
child: Builder(
builder: (context) {
final map = FutureBuilder(
future: maps,
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(:final MapSource data) => _RouteMap(
source: data,
near: near,
),
AsyncSnapshot(connectionState: ConnectionState.done) =>
Center(child: Text(l10n.workoutMapUnavailable)),
_ => const Center(child: AppLoadingIndicator()),
},
);
// From expanded windows on, the details beside the map, as
// Material's supporting pane (D63).
return context.windowSize.isAtLeastExpanded
? Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: map),
const SizedBox(width: 400, child: _Details()),
],
)
: Column(
children: [
Expanded(flex: 3, child: map),
const Expanded(flex: 2, child: _Details()),
],
);
},
),
),
),
@ -197,8 +236,10 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
/// offers to take it off, the button above takes the last off, and a place searched for and chosen is the
/// next (D50). The camera fits the route as it opens, and
/// goes to a new route's first waypoint once it is known.
class const _RouteMap({required final MapSource source})
extends StatefulWidget {
class const _RouteMap({
required final MapSource source,
final Future<LatLng?>? near,
}) extends StatefulWidget {
@override
State<_RouteMap> createState() => _RouteMapState();
}
@ -220,11 +261,38 @@ class _RouteMapState() extends State<_RouteMap> {
/// What the camera fits as the map opens: the route as it was opened.
late final List<LatLng> _fit;
/// Whether the map is drawn, so that its camera can be moved.
var _ready = false;
/// Where a new route's map goes as it opens, once known (D63).
LatLng? _near;
@override
void initState() {
super.initState();
final route = context.read<RouteEditorCubit>().state.route;
_fit = [...?route.planned?.points, ...route.waypoints];
unawaited(
widget.near?.then((near) {
if (mounted) {
_near = near;
_goNear();
}
}),
);
}
/// Closer, near the person, while the map still shows the region and no
/// waypoint is set: zoom 14, a town's district, as a map app opens on
/// where one is.
void _goNear() {
final near = _near;
if (_ready &&
near != null &&
context.read<RouteEditorCubit>().state.route.waypoints.isEmpty &&
_map.camera.zoom < 10) {
_map.move(near, 14);
}
}
@override
@ -251,9 +319,6 @@ class _RouteMapState() extends State<_RouteMap> {
},
builder: (context, state) {
final route = state.route;
final searches = context.select<PlaceSearchCubit, bool>(
(search) => search.state.offered ?? false,
);
final waypoints = [
for (final (index, point) in route.waypoints.indexed) ...[
if (_pulled case (final pulled, final at) when pulled == index) at,
@ -283,6 +348,10 @@ class _RouteMapState() extends State<_RouteMap> {
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
),
onTap: (_, point) => cubit().add(point),
onMapReady: () {
_ready = true;
_goNear();
},
),
children: [
train.mapTiles(widget.source),
@ -339,11 +408,7 @@ class _RouteMapState() extends State<_RouteMap> {
),
],
),
RichAttributionWidget(
attributions: [
TextSourceAttribution(widget.source.attribution),
],
),
mapAttribution(context, widget.source),
],
),
if (state.planning)
@ -356,27 +421,31 @@ class _RouteMapState() extends State<_RouteMap> {
Positioned(
top: 8,
right: 8,
child: IconButton.filledTonal(
tooltip: l10n.routeUndo,
icon: const Icon(LucideIcons.undo2),
onPressed: route.waypoints.isEmpty
? null
: () => cubit().undo(),
child: Column(
children: [
IconButton.filledTonal(
tooltip: l10n.routeUndo,
icon: const Icon(LucideIcons.undo2),
onPressed: route.waypoints.isEmpty
? null
: () => cubit().undo(),
),
// How the map is used, one tap away rather than over it
// (D63).
DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.secondaryContainer,
),
child: HelpButton(
l10n.routeTapHint,
title: l10n.routeHelpTitle,
),
),
],
),
),
if (route.waypoints.length < 2 && state.offered != false)
Positioned(
left: 16,
right: 64,
top: searches ? 72 : 12,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(l10n.routeTapHint),
),
),
),
// Last, so that the places found lie over the hint.
// Last, so that the places found lie over the map's buttons.
Positioned(
left: 8,
right: 64,

View file

@ -97,10 +97,7 @@ class _RoutesMapState() extends State<RoutesMap> {
],
),
),
if (snapshot.data case final source?)
RichAttributionWidget(
attributions: [TextSourceAttribution(source.attribution)],
),
if (snapshot.data case final source?) mapAttribution(context, source),
],
),
);

View file

@ -0,0 +1,31 @@
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:latlong2/latlong.dart';
/// How many of the newest workouts are looked into for a track.
const _looked = 5;
/// Where the newest of the person's workouts with a track started, at its
/// first fix (ADR 0030, D63): where a new route's map opens when the phone
/// can't say where it is; null where none of the newest has a track.
Future<LatLng?> lastWorkoutStart(WorkoutRepository workouts) async {
final newest = [
for (final stored in await workouts.all())
if (stored.content case final content?) (stored.id, content.startedAt),
]..sort((a, b) => b.$2.compareTo(a.$2));
for (final (id, _) in newest.take(_looked)) {
for (final chunk in await workouts.series(id)) {
if (chunk.kind != SeriesKind.position) {
continue;
}
final lats = chunk.columns['lat'] ?? const [];
final lons = chunk.columns['lon'] ?? const [];
for (var i = 0; i < lats.length && i < lons.length; i++) {
if ((lats[i], lons[i]) case (final lat?, final lon?)) {
return LatLng(lat / 1e7, lon / 1e7);
}
}
}
}
return null;
}

View file

@ -56,9 +56,11 @@ import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.d
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/week_card.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_screen.dart';
import 'package:halkyon_train_flutter/src/tabs/maps_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -289,6 +291,21 @@ void main() {
await tester.pumpAndSettle();
}
/// The route planner's help, behind its question mark rather than over
/// the map (D63): opened, read and closed.
Future<void> expectRouteHelp(WidgetTester tester) async {
const help =
'Tap the map to add a waypoint, tap one to take it off. Hold '
'one to drag it, or hold the line to pull out another.';
expect(find.text(help), findsNothing);
await tester.tap(find.byTooltip("What's this?"));
await tester.pumpAndSettle();
expect(find.text('Planning a route'), findsOneWidget);
expect(find.text(help), findsOneWidget);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
/// Goes to the tab [label] of the navigation bar (ADR 0030, D60).
Future<void> goTab(WidgetTester tester, String label) async {
await tester.tap(
@ -2122,20 +2139,16 @@ void main() {
await pass(tester);
expect(find.text('Save this workout?'), findsOneWidget);
// Saved, its page opens, in Home's tab (D60).
// Saved, its page opens, over the tabs (D63).
await tester.tap(find.text('Save'));
await pass(tester, 2);
final ride = find.textContaining(RegExp('ride', caseSensitive: false));
expect(
find.descendant(of: find.byType(AppBar), matching: ride),
findsOneWidget,
);
final page = find.byType(WorkoutScreen);
expect(find.descendant(of: page, matching: ride), findsWidgets);
expect(find.byType(FloatingNavigationBar), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
expect(
find.descendant(of: find.byType(AppBar), matching: ride),
findsNothing,
);
expect(page, findsNothing);
expect(ride, findsWidgets);
expect(startFor(), findsNothing);
final saved = (await workouts.all()).single.content!;
@ -2404,7 +2417,9 @@ void main() {
StepKind.recovery,
]);
// The workout, in Home.
// The workout, over the tabs (D63), and again from Home.
await tester.pageBack();
await tester.pumpAndSettle();
await goTab(tester, 'Home');
await tester.tap(find.text('0:50'));
await tester.runAsync(pumpEventQueue);
@ -3049,13 +3064,16 @@ void main() {
folder.deleteSync(recursive: true);
});
/// Maps, on a phone at [at] (null: a device that can't tell).
/// Maps, on a phone at [at] (null: a device that can't tell), last known
/// at [last], with [workouts].
Future<void> openRoutes(
WidgetTester tester, {
({double lat, double lon})? at,
({double lat, double lon})? last,
OfflineMaps? offlineMaps,
RouteFiles? routeFiles,
PlaceSearchGateway? placeSearch,
WorkoutRepository? workouts,
}) async {
await start(
tester,
@ -3065,10 +3083,13 @@ void main() {
placeSearch: placeSearch,
offlineMaps: offlineMaps,
routeFiles: routeFiles,
recording: at == null
workouts: workouts,
recording: at == null && last == null
? null
: (
platform: FakeRecorderPlatform()..at = at,
platform: FakeRecorderPlatform()
..at = at
..lastAt = last,
file: File('${folder.path}/recording.txt'),
devices: {
for (final kind in DeviceKind.values)
@ -3109,13 +3130,7 @@ void main() {
);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(
find.text(
'Tap the map to add a waypoint, tap one to take it off. Hold '
'one to drag it, or hold the line to pull out another.',
),
findsOneWidget,
);
await expectRouteHelp(tester);
await tester.enterText(find.byType(TextField).first, 'Tempelhof');
// Past the pause after typing.
@ -3154,13 +3169,7 @@ void main() {
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
expect(
find.text(
'Tap the map to add a waypoint, tap one to take it off. Hold '
'one to drag it, or hold the line to pull out another.',
),
findsOneWidget,
);
await expectRouteHelp(tester);
await tapMap(tester, const Offset(60, 40));
expect(
@ -3203,6 +3212,82 @@ void main() {
expect(route.content?.planned, isNotNull);
});
group("a new route's map opens near the person (D63)", () {
/// The planner's camera.
MapCamera camera(WidgetTester tester) => MapCamera.of(
tester.element(
find.descendant(
of: find.byType(FlutterMap),
matching: find.byType(MarkerLayer),
),
),
);
testWidgets('where the phone was last known, closer, before any fix', (
tester,
) async {
await openRoutes(tester, last: (lat: 52.52, lon: 13.405));
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(camera(tester).zoom, 14);
expect(camera(tester).center.latitude, closeTo(52.52, 1e-6));
expect(find.text('1'), findsNothing, reason: 'no waypoint set');
});
testWidgets('else where the newest workout with a track started', (
tester,
) async {
final workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
final start = DateTime.utc(2026, 10, 20, 7);
await workouts.save(
WorkoutContent(
activity: Activity.running,
startedAt: start,
endedAt: start.add(const Duration(minutes: 20)),
),
chunksOf(
SeriesKind.position,
start: start,
times: const [0],
columns: {
'lat': [475500000],
'lon': [96800000],
},
),
);
await openRoutes(tester, workouts: workouts);
await tester.tap(find.text('New route'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(camera(tester).zoom, 14);
expect(camera(tester).center.latitude, closeTo(47.55, 1e-6));
expect(camera(tester).center.longitude, closeTo(9.68, 1e-6));
});
testWidgets('else the region, as before', (tester) async {
await openRoutes(tester);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(camera(tester).zoom, lessThan(10));
});
});
testWidgets("from expanded windows on, the planner's details beside its "
'map (D63)', (tester) async {
await openRoutes(tester);
tester.view.physicalSize = const Size(1280, 800);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(
tester.getTopRight(find.byType(FlutterMap)).dx,
lessThanOrEqualTo(tester.getTopLeft(find.text('Name')).dx),
);
});
testWidgets('the activity picks the profile, which plans the way again', (
tester,
) async {
@ -3758,8 +3843,9 @@ void main() {
);
});
testWidgets('home and a workout’s page run on beneath the floating bar, '
'their lists ending clear of it', (tester) async {
testWidgets('home runs on beneath the floating bar, its list ending '
"clear of it; a workout's page covers the bar, its sheet ending clear "
"of the screen's foot (D63)", (tester) async {
for (var day = 1; day <= 12; day++) {
await saveRun(DateTime(2026, 10, day, 7, 30));
}
@ -3790,7 +3876,21 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Morning run').first);
await tester.pumpAndSettle();
await endsClearOfTheBar(find.byType(Scrollable).last);
expect(bar, findsNothing);
final sheet = find.descendant(
of: find.byType(DraggableScrollableSheet),
matching: find.byType(Scrollable),
);
for (var i = 0; i < 3; i++) {
await tester.drag(sheet, const Offset(0, -5000));
await tester.pumpAndSettle();
}
final items = find.descendant(of: sheet, matching: find.byType(Text));
expect(
tester.getBottomLeft(items.last).dy,
lessThanOrEqualTo(screen - 48),
);
});
testWidgets('above them, this week: its time, distance and workouts, and '
@ -3891,14 +3991,18 @@ void main() {
expect(find.text('2.50 km'), findsOneWidget);
expect(find.text('20 m'), findsOneWidget);
expect(find.text('whole time'), findsOneWidget);
expect(
find.text(
// What the map's source sees, one tap away (D63).
const sees =
"Map by OpenFreeMap: it sees your device's address and the area "
'shown, not your track.',
),
findsOneWidget,
);
'shown, not your track.';
expect(find.text(sees), findsNothing);
await tester.tap(find.byTooltip("What's this?"));
await tester.pumpAndSettle();
expect(find.text('About the map'), findsOneWidget);
expect(find.text(sees), findsOneWidget);
expect(find.text('What OpenFreeMap keeps'), findsOneWidget);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('km 3'), 200);
expect(find.text('Splits'), findsOneWidget);
expect(find.text('km 1'), findsOneWidget);
@ -3925,6 +4029,8 @@ void main() {
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip("What's this?"));
await tester.pumpAndSettle();
expect(
find.text(
@ -3936,6 +4042,115 @@ void main() {
expect(find.textContaining('sees your device'), findsNothing);
});
group("a workout's page as Strava's (D63)", () {
MapCamera camera(WidgetTester tester) =>
MapCamera.of(tester.element(find.byType(PolylineLayer)));
Future<void> openRun(WidgetTester tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await startWith(tester);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
}
testWidgets('its map to the foot of the screen, under a sheet with its '
'name; pulled down, the map fits the track anew, closer', (
tester,
) async {
await openRun(tester);
expect(
tester.getBottomLeft(find.byType(FlutterMap)).dy,
tester.view.physicalSize.height,
);
final sheet = find.byType(DraggableScrollableSheet);
expect(
find.descendant(of: sheet, matching: find.text('Morning run')),
findsOneWidget,
);
final before = camera(tester);
await tester.drag(find.text('2.50 km'), const Offset(0, 400));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(camera(tester).zoom, greaterThan(before.zoom));
expect(
camera(tester).center.latitude,
greaterThan(before.center.latitude),
reason:
'the track lower on the larger map: the map looks south of it',
);
});
testWidgets("pulled up, the round back button turns into a bar's "
'chevron that lowers the sheet again', (tester) async {
await openRun(tester);
expect(find.byTooltip('Back'), findsOneWidget);
expect(find.byTooltip('Show the map'), findsNothing);
await tester.drag(find.text('2.50 km'), const Offset(0, -2000));
await tester.pumpAndSettle();
expect(find.byTooltip('Show the map'), findsOneWidget);
expect(find.byTooltip('Back'), findsNothing);
await tester.tap(find.byTooltip('Show the map'));
await tester.pumpAndSettle();
expect(find.byTooltip('Back'), findsOneWidget);
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
expect(find.byType(WorkoutScreen), findsNothing);
});
testWidgets('from expanded windows on, the map beside the rest', (
tester,
) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await startWith(tester);
tester.view.physicalSize = const Size(1280, 800);
await tester.pumpAndSettle();
await tester.tap(find.text('Morning run').last);
await tester.pumpAndSettle();
expect(find.byType(DraggableScrollableSheet), findsNothing);
expect(
tester.getTopRight(find.byType(FlutterMap)).dx,
lessThanOrEqualTo(tester.getTopLeft(find.text('2.50 km')).dx),
);
});
testWidgets("the map's credit opens on the theme's surface, in its "
'colours', (tester) async {
await openRun(tester);
final credit = tester.widget<RichAttributionWidget>(
find.byType(RichAttributionWidget),
);
final scheme = Theme.of(
tester.element(find.byType(RichAttributionWidget)),
).colorScheme;
expect(credit.popupBackgroundColor, scheme.surfaceContainerHigh);
await tester.tap(find.byTooltip('Map credits'));
await tester.pumpAndSettle();
final text = find.textContaining('OpenFreeMap © OpenMapTiles');
expect(text, findsOneWidget);
expect(
tester
.widget<Text>(
find
.descendant(
of: find.byType(TextSourceAttribution),
matching: find.byType(Text),
)
.first,
)
.style
?.color,
scheme.onSurface,
);
});
});
testWidgets("a workout with a strap shows the heart's average and highest "
'rate, by split too, and its chart', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30), strap: true);

Some files were not shown because too many files have changed in this diff Show more