Merge branch 'train-maps' into integration

This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 23:13:32 +01:00
commit 10744bcd56
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 1588 additions and 360 deletions

View file

@ -110,6 +110,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()
}
}
@ -280,6 +281,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