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
27 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 }) "show" -> result.success(RecorderService.show(call.arguments as String).let { null })
"isRecording" -> result.success(RecorderService.isRecording()) "isRecording" -> result.success(RecorderService.isRecording())
"position" -> position(result) "position" -> position(result)
"lastPosition" -> result.success(lastPosition())
else -> result.notImplemented() 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 { private companion object {
const val RECORDER_REQUEST = 1 const val RECORDER_REQUEST = 1
const val FRESH_MS = 120_000L 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.", "workoutMapOwn": "Karte von deiner Instanz, {name}, aus OpenStreetMap: sie speichert nichts vom gezeigten Ausschnitt.",
"workoutMapSourceAbout": "Was {name} speichert", "workoutMapSourceAbout": "Was {name} speichert",
"workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.", "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.", "workoutNotFound": "Dieses Training gibt es nicht mehr.",
"recordHeartRate": "Herzfrequenz", "recordHeartRate": "Herzfrequenz",

View file

@ -120,6 +120,14 @@
"workoutMapSourceAbout": "What {name} keeps", "workoutMapSourceAbout": "What {name} keeps",
"@workoutMapSourceAbout": {"description": "A link to the map source's page about what it keeps.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}}, "@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.", "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.", "workoutNotFound": "This workout is gone.",
"recordHeartRate": "Heart rate", "recordHeartRate": "Heart rate",

View file

@ -622,6 +622,30 @@ abstract class AppLocalizations {
/// **'The map can\'t be loaded now.'** /// **'The map can\'t be loaded now.'**
String get workoutMapUnavailable; 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. /// No description provided for @workoutNotFound.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -344,6 +344,18 @@ class AppLocalizationsDe extends AppLocalizations {
String get workoutMapUnavailable => String get workoutMapUnavailable =>
'Die Karte kann gerade nicht geladen werden.'; '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 @override
String get workoutNotFound => 'Dieses Training gibt es nicht mehr.'; String get workoutNotFound => 'Dieses Training gibt es nicht mehr.';

View file

@ -341,6 +341,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get workoutMapUnavailable => 'The map can\'t be loaded now.'; 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 @override
String get workoutNotFound => 'This workout is gone.'; String get workoutNotFound => 'This workout is gone.';

View file

@ -1,8 +1,8 @@
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart'; 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 /// The app's locations (ADR 0030, D60): the five tabs, a day's page in
/// Home and a day's in Profile, and the editors and the recording over the /// Profile, and a workout's page (D63), the editors and the recording over
/// tabs, and Settings. /// the tabs, and Settings.
abstract final class TrainPaths() { abstract final class TrainPaths() {
/// Home, where the app opens: the workouts. /// Home, where the app opens: the workouts.
static const home = '/'; static const home = '/';
@ -19,7 +19,7 @@ abstract final class TrainPaths() {
/// Profile: the days. /// Profile: the days.
static const profile = '/profile'; 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'; static String workout(String id) => '/workouts/$id';
/// The page of the day [date], in Profile, by the band's name for it. /// 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/record_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/train_shell.dart'; import 'package:halkyon_train_flutter/src/tabs/train_shell.dart';
import 'package:halkyon_ui/halkyon_ui.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 /// 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 /// in a shell, each with its own stack of screens, and the editors, the
/// recording over them. Every route is built inside the person's scope, so /// 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 /// 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 /// handed to its editor as it is (`extra`): a new one exists nowhere else
/// yet. /// 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: [ routes: [
StatefulShellRoute( StatefulShellRoute(
builder: (context, state, shell) => TrainShell(shell: shell), builder: (context, state, shell) => TrainShell(shell: shell),
@ -38,8 +43,11 @@ GoRouter buildTrainRouter() => GoRouter(
path: TrainPaths.home, path: TrainPaths.home,
builder: (context, state) => const HomeTab(), builder: (context, state) => const HomeTab(),
routes: [ routes: [
// In Home, so that back leads there, but over the shell,
// its map to the foot of the screen (D63).
GoRoute( GoRoute(
path: 'workouts/:id', path: 'workouts/:id',
parentNavigatorKey: root,
builder: (context, state) => builder: (context, state) =>
workoutPage(context, state.pathParameters['id']!), workoutPage(context, state.pathParameters['id']!),
), ),

View file

@ -21,3 +21,39 @@ Map<String, String>? tileHeadersOf(MapSource source) =>
final key? => {'authorization': 'Bearer $key'}, final key? => {'authorization': 'Bearer $key'},
null => null, 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 '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_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/map_style.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.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_map_tiles/vector_map_tiles.dart';
import 'package:vector_tile_renderer/vector_tile_renderer.dart' import 'package:vector_tile_renderer/vector_tile_renderer.dart'
show SpriteIndexReader, ThemeReader; show SpriteIndexReader, ThemeReader;
@ -52,7 +55,8 @@ Future<Style> _keptStyle(
final sprite = style.spriteIndex; final sprite = style.spriteIndex;
final image = style.spriteImage; final image = style.spriteImage;
return Style( 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({ providers: TileProviders({
for (final MapEntry(:key, :value) in style.sources.entries) for (final MapEntry(:key, :value) in style.sources.entries)
key: _KeptFirst(maps, key, value, tileHeadersOf(source)), 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, _ => 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 @override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>(); 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. /// the location permission ([ask] first) or a fix within 15 s.
Future<({double lat, double lon})?> position(); 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 /// Every line of the file written so far, then each new one as it is
/// written, while a recording runs. /// written, while a recording runs.
Stream<String> lines(); Stream<String> lines();

View file

@ -586,9 +586,7 @@ class _RecordingMapState() extends State<_RecordingMap> {
], ],
), ),
if (snapshot.data case final source?) if (snapshot.data case final source?)
RichAttributionWidget( mapAttribution(context, source),
attributions: [TextSourceAttribution(source.attribution)],
),
], ],
), ),
if (live.route case final route?) 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_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:go_router/go_router.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/route/widget/route_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart' import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
show ChartPoint; 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/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_chart.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.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(), 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. /// Where the phone is, the location permission asked for first.
Future<LatLng?> _here(RecorderPlatform platform) async { Future<LatLng?> _here(RecorderPlatform platform) async {
if (!(await platform.ask({ 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 /// 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 /// planned: its distance, climb and descent, its elevation and its
/// surfaces. Saved with the button, deleted after asking. /// surfaces. Saved with the button, deleted after asking.
class const RouteScreen({required final Future<MapSource?> maps, super.key}) class const RouteScreen({
extends StatelessWidget { required final Future<MapSource?> maps,
final Future<LatLng?>? near,
super.key,
}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -130,24 +156,37 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
], ],
), ),
body: SafeArea( body: SafeArea(
child: Column( child: Builder(
children: [ builder: (context) {
Expanded( final map = FutureBuilder(
flex: 3,
child: FutureBuilder(
future: maps, future: maps,
builder: (context, snapshot) => switch (snapshot) { builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(:final MapSource data) => _RouteMap( AsyncSnapshot(:final MapSource data) => _RouteMap(
source: data, source: data,
near: near,
), ),
AsyncSnapshot(connectionState: ConnectionState.done) => AsyncSnapshot(connectionState: ConnectionState.done) =>
Center(child: Text(l10n.workoutMapUnavailable)), Center(child: Text(l10n.workoutMapUnavailable)),
_ => const Center(child: AppLoadingIndicator()), _ => 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()), 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 /// 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 /// next (D50). The camera fits the route as it opens, and
/// goes to a new route's first waypoint once it is known. /// goes to a new route's first waypoint once it is known.
class const _RouteMap({required final MapSource source}) class const _RouteMap({
extends StatefulWidget { required final MapSource source,
final Future<LatLng?>? near,
}) extends StatefulWidget {
@override @override
State<_RouteMap> createState() => _RouteMapState(); 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. /// What the camera fits as the map opens: the route as it was opened.
late final List<LatLng> _fit; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
final route = context.read<RouteEditorCubit>().state.route; final route = context.read<RouteEditorCubit>().state.route;
_fit = [...?route.planned?.points, ...route.waypoints]; _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 @override
@ -251,9 +319,6 @@ class _RouteMapState() extends State<_RouteMap> {
}, },
builder: (context, state) { builder: (context, state) {
final route = state.route; final route = state.route;
final searches = context.select<PlaceSearchCubit, bool>(
(search) => search.state.offered ?? false,
);
final waypoints = [ final waypoints = [
for (final (index, point) in route.waypoints.indexed) ...[ for (final (index, point) in route.waypoints.indexed) ...[
if (_pulled case (final pulled, final at) when pulled == index) at, 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, flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
), ),
onTap: (_, point) => cubit().add(point), onTap: (_, point) => cubit().add(point),
onMapReady: () {
_ready = true;
_goNear();
},
), ),
children: [ children: [
train.mapTiles(widget.source), train.mapTiles(widget.source),
@ -339,11 +408,7 @@ class _RouteMapState() extends State<_RouteMap> {
), ),
], ],
), ),
RichAttributionWidget( mapAttribution(context, widget.source),
attributions: [
TextSourceAttribution(widget.source.attribution),
],
),
], ],
), ),
if (state.planning) if (state.planning)
@ -356,27 +421,31 @@ class _RouteMapState() extends State<_RouteMap> {
Positioned( Positioned(
top: 8, top: 8,
right: 8, right: 8,
child: IconButton.filledTonal( child: Column(
children: [
IconButton.filledTonal(
tooltip: l10n.routeUndo, tooltip: l10n.routeUndo,
icon: const Icon(LucideIcons.undo2), icon: const Icon(LucideIcons.undo2),
onPressed: route.waypoints.isEmpty onPressed: route.waypoints.isEmpty
? null ? null
: () => cubit().undo(), : () => 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,
), ),
if (route.waypoints.length < 2 && state.offered != false) child: HelpButton(
Positioned( l10n.routeTapHint,
left: 16, title: l10n.routeHelpTitle,
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( Positioned(
left: 8, left: 8,
right: 64, right: 64,

View file

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

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

@ -1,3 +1,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -6,9 +10,9 @@ 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/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart'; import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_paths.dart'; import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/track_camera.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart'; import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart'; import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart'; import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/strength/widget/workout_strength_view.dart'; import 'package:halkyon_train_flutter/src/feature/strength/widget/workout_strength_view.dart';
import 'package:halkyon_train_flutter/src/feature/weather/widget/workout_weather_view.dart'; import 'package:halkyon_train_flutter/src/feature/weather/widget/workout_weather_view.dart';
@ -40,11 +44,17 @@ Widget workoutPage(BuildContext context, String id) {
); );
} }
/// A workout's page (ADR 0030, D24): its track on the instance's map, top, /// A workout's page (ADR 0030, D24, D63), over the tabs: with a track on the
/// where it has one; below its name, when it was, its totals, its notes, its /// instance's map, the map to the foot of the screen and the rest on a sheet
/// splits and its charts. Edit names it and writes its notes; Export hands /// over it, as Strava's: pulled down, the map is larger; pulled up, the
/// it on as GPX, TCX or FIT (D49), where the device hands files on; Delete /// round buttons over the map fold into an app bar; settled, the map fits the
/// asks first, goes back home and offers to undo it there. /// track anew into what the sheet leaves of it. From expanded windows on,
/// the map and the rest side by side. Without a track, or without the map,
/// the rest alone. Below the name, when it was, its totals, its notes, its
/// splits and its charts; what the map's source sees one tap away. Edit
/// names it and writes its notes; Export hands it on as GPX, TCX or FIT
/// (D49), where the device hands files on; Delete asks first, goes back
/// home and offers to undo it there.
class const WorkoutScreen({super.key}) extends StatelessWidget { class const WorkoutScreen({super.key}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -53,48 +63,92 @@ class const WorkoutScreen({super.key}) extends StatelessWidget {
builder: (context, state) { builder: (context, state) {
final content = state.content; final content = state.content;
final analysis = state.analysis; final analysis = state.analysis;
return Scaffold( final title = content == null ? '' : workoutTitle(l10n, content);
appBar: AppBar( final actions = content == null
title: Text(content == null ? '' : workoutTitle(l10n, content)), ? const <_Action>[]
: _actions(context, content, analysis);
AppBar bar() => AppBar(
title: Text(title),
actions: [ actions: [
if (content != null) ...[ for (final action in actions)
IconButton( IconButton(
tooltip: action.tooltip,
icon: Icon(action.icon),
onPressed: action.onPressed,
),
],
);
return switch ((state.phase, content, analysis, state.map)) {
(
WorkoutPhase.loaded,
final content?,
final analysis?,
MapFrom(:final source),
)
when analysis.track.isNotEmpty =>
context.windowSize.isAtLeastExpanded
? Scaffold(
appBar: bar(),
body: _SidePane(
content: content,
analysis: analysis,
source: source,
),
)
: _MapPage(
title: title,
actions: actions,
content: content,
analysis: analysis,
source: source,
),
(WorkoutPhase.loaded, final content?, final analysis?, final map) =>
Scaffold(
appBar: bar(),
body: _Plain(content: content, analysis: analysis, map: map),
),
(WorkoutPhase.gone, _, _, _) => Scaffold(
appBar: bar(),
body: Center(child: Text(l10n.workoutNotFound)),
),
_ => Scaffold(
appBar: bar(),
body: const Center(child: AppLoadingIndicator()),
),
};
},
);
}
/// Edit, Export where the device hands files on, and Delete.
List<_Action> _actions(
BuildContext context,
WorkoutContent content,
WorkoutAnalysis? analysis,
) {
final l10n = AppLocalizations.of(context);
return [
(
tooltip: l10n.workoutEdit, tooltip: l10n.workoutEdit,
icon: const Icon(LucideIcons.pencil), icon: LucideIcons.pencil,
onPressed: () => _edit(context, content), onPressed: () => _edit(context, content),
), ),
if (PersonScope.of<TrainPerson>(context).routeFiles != null) if (PersonScope.of<TrainPerson>(context).routeFiles != null)
IconButton( (
tooltip: l10n.workoutExport, tooltip: l10n.workoutExport,
icon: const Icon(LucideIcons.share2), icon: LucideIcons.share2,
onPressed: () => _export( onPressed: () => _export(
context, context,
content, content,
located: analysis?.track.isNotEmpty ?? false, located: analysis?.track.isNotEmpty ?? false,
), ),
), ),
IconButton( (
tooltip: l10n.workoutDelete, tooltip: l10n.workoutDelete,
icon: const Icon(LucideIcons.trash2), icon: LucideIcons.trash2,
onPressed: () => _delete(context), onPressed: () => _delete(context),
), ),
], ];
],
),
body: switch ((state.phase, content, analysis)) {
(WorkoutPhase.loaded, final content?, final analysis?) => _Workout(
content: content,
analysis: analysis,
map: state.map,
),
(WorkoutPhase.gone, _, _) => Center(
child: Text(l10n.workoutNotFound),
),
_ => const Center(child: AppLoadingIndicator()),
},
);
},
);
} }
Future<void> _edit(BuildContext context, WorkoutContent content) async { Future<void> _edit(BuildContext context, WorkoutContent content) async {
@ -194,22 +248,363 @@ Future<void> _bringInTrack(BuildContext context) async {
} }
} }
/// The page under the app bar: the map fixed at the top, the rest scrolling /// A button of the page's: its tooltip, its icon and what it does.
/// beneath it. typedef _Action = ({String tooltip, IconData icon, VoidCallback onPressed});
class const _Workout({
/// How far up the sheet starts and snaps back to, of the page's height
/// (D63): the map above it, as Strava's opens.
const _sheetStart = 0.55;
/// How far down the sheet goes: its name and when it was still showing.
const _sheetLowest = 0.15;
/// The workout's points on the map, a piece between each pause.
List<List<LatLng>> _piecesOf(WorkoutAnalysis analysis) => [
for (final piece in analysis.track)
[for (final fix in piece) LatLng(fix.latE7 / 1e7, fix.lonE7 / 1e7)],
];
/// The page on a phone or a tablet, with a track (D63): the map to the foot,
/// the sheet over it, the buttons over the map folding into an app bar as
/// the sheet comes up, the map fitting the track anew once it settles.
class const _MapPage({
required final String title,
required final List<_Action> actions,
required final WorkoutContent content,
required final WorkoutAnalysis analysis,
required final MapSource source,
}) extends StatefulWidget {
@override
State<_MapPage> createState() => _MapPageState();
}
class _MapPageState()
extends State<_MapPage>
with SingleTickerProviderStateMixin {
final _map = MapController();
final _sheet = DraggableScrollableController();
/// How far up the sheet is, of the page's height.
final _extent = ValueNotifier(_sheetStart);
/// The camera's way to the track fitted anew.
late final AnimationController _move =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
)..addListener(() {
final to = _to;
final from = _from;
if (to != null && from != null) {
final at = cameraBetween(
from,
to,
Curves.easeInOutCubic.transform(_move.value),
);
_map.move(at.center, at.zoom);
}
});
CameraAt? _from;
CameraAt? _to;
/// Waits for the sheet to settle before the map moves.
Timer? _settle;
late final _pieces = _piecesOf(widget.analysis);
late final _all = [for (final piece in _pieces) ...piece];
@override
void dispose() {
_settle?.cancel();
_move.dispose();
_extent.dispose();
_sheet.dispose();
_map.dispose();
super.dispose();
}
/// How high the sheet goes at most: to the app bar's foot.
double _highest(Size view, double top) =>
(view.height - top - trackButtonsHeight) / view.height;
/// The track fitted into what the sheet at [extent] leaves of the map.
EdgeInsets _padding(Size view, double top, double extent) =>
trackFitPadding(view: view, top: top, covered: extent * view.height);
bool _moved(DraggableScrollableNotification notification) {
_extent.value = notification.extent;
_settle?.cancel();
_settle = Timer(const Duration(milliseconds: 200), _refit);
return false;
}
/// Moves the camera, eased, to fit the track into the map left free.
void _refit() {
if (!mounted || _all.length < 2) {
return;
}
final view = MediaQuery.sizeOf(context);
final top = MediaQuery.paddingOf(context).top;
if (_extent.value >= _highest(view, top) - 0.01) {
// The sheet covers the map: nothing to see moving.
return;
}
final camera = _map.camera;
final fitted = CameraFit.coordinates(
coordinates: _all,
padding: _padding(view, top, _extent.value),
maxZoom: 17,
).fit(camera);
_from = (center: camera.center, zoom: camera.zoom);
_to = (center: fitted.center, zoom: fitted.zoom);
_move.forward(from: 0);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final view = MediaQuery.sizeOf(context);
final top = MediaQuery.paddingOf(context).top;
final highest = _highest(view, top);
return Scaffold(
body: Stack(
children: [
Positioned.fill(
child: _TrackMap(
controller: _map,
pieces: _pieces,
source: widget.source,
fit: _padding(view, top, _sheetStart),
// The credit's button just above the sheet.
creditClear: _extent,
),
),
NotificationListener<DraggableScrollableNotification>(
onNotification: _moved,
child: DraggableScrollableSheet(
controller: _sheet,
initialChildSize: _sheetStart,
minChildSize: _sheetLowest,
maxChildSize: highest,
snap: true,
snapSizes: const [_sheetStart],
builder: (context, scroll) => Material(
color: theme.colorScheme.surface,
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
clipBehavior: Clip.antiAlias,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: ListView(
controller: scroll,
padding: EdgeInsets.fromLTRB(
16,
0,
16,
32 + MediaQuery.paddingOf(context).bottom,
),
children: [
const _Handle(),
// Its name on the sheet, as Strava's, the app bar
// showing it only pulled up.
Text(
widget.title,
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
..._details(
context,
widget.content,
widget.analysis,
shown: widget.source,
),
],
),
),
),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: ValueListenableBuilder(
valueListenable: _extent,
builder: (context, extent, _) => _Header(
shown: barShown(extent / highest),
title: widget.title,
actions: widget.actions,
onLower: () => _sheet.animateTo(
_sheetStart,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
),
),
),
),
],
),
);
}
}
/// The sheet's grip, on top of it.
class const _Handle() extends StatelessWidget {
@override
Widget build(BuildContext context) => Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 12),
width: 32,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(2),
),
),
);
}
/// The buttons over the map, round on it, folding into an app bar as the
/// sheet comes up ([shown], 0 to 1): the bar's colour and its title come in,
/// the buttons' rounds go, and the back arrow turns into a chevron down that
/// lowers the sheet again.
class const _Header({
required final double shown,
required final String title,
required final List<_Action> actions,
required final VoidCallback onLower,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final lowers = shown > 0.5;
Widget round(Widget button) => DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: scheme.surfaceContainerHigh.withValues(alpha: 1 - shown),
),
child: button,
);
return ColoredBox(
color: scheme.surface.withValues(alpha: shown),
child: SafeArea(
bottom: false,
child: SizedBox(
height: trackButtonsHeight,
child: Row(
children: [
const SizedBox(width: 4),
round(
IconButton(
tooltip: lowers
? AppLocalizations.of(context).workoutShowMap
: MaterialLocalizations.of(context).backButtonTooltip,
// A quarter turn: the arrow back points down.
icon: Transform.rotate(
angle: -math.pi / 2 * shown,
child: const Icon(LucideIcons.chevronLeft),
),
onPressed: lowers
? onLower
: () => Navigator.of(context).maybePop(),
),
),
const SizedBox(width: 12),
Expanded(
child: Opacity(
opacity: shown,
child: Text(
title,
style: theme.textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
),
),
),
for (final action in actions) ...[
round(
IconButton(
tooltip: action.tooltip,
icon: Icon(action.icon),
onPressed: action.onPressed,
),
),
const SizedBox(width: 4),
],
const SizedBox(width: 4),
],
),
),
),
);
}
}
/// The page from expanded windows on, with a track (D63): the map beside
/// the rest, as Material's supporting pane.
class const _SidePane({
required final WorkoutContent content,
required final WorkoutAnalysis analysis,
required final MapSource source,
}) extends StatelessWidget {
static const double _paneWidth = 400;
@override
Widget build(BuildContext context) => SafeArea(
top: false,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 0, 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: _TrackMap(
pieces: _piecesOf(analysis),
source: source,
fit: const EdgeInsets.all(trackFitMargin),
),
),
),
),
SizedBox(
width: _paneWidth,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
children: _details(context, content, analysis, shown: source),
),
),
],
),
);
}
/// The page without a map: the rest alone, saying where the map couldn't be
/// loaded.
class const _Plain({
required final WorkoutContent content, required final WorkoutContent content,
required final WorkoutAnalysis analysis, required final WorkoutAnalysis analysis,
required final WorkoutMap? map, required final WorkoutMap? map,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context); final theme = Theme.of(context);
final started = content.startedAt.toLocal(); return SafeArea(
final hasTrack = analysis.track.isNotEmpty; top: false,
final details = ListView( bottom: false,
// Running on beneath the floating bar, ending clear of it. child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: ListView(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
16, 16,
16, 16,
@ -217,7 +612,39 @@ class const _Workout({
32 + MediaQuery.paddingOf(context).bottom, 32 + MediaQuery.paddingOf(context).bottom,
), ),
children: [ children: [
if (hasTrack) ...[_MapNote(map: map), const SizedBox(height: 16)], if (map is MapUnavailable && analysis.track.isNotEmpty) ...[
Text(
AppLocalizations.of(context).workoutMapUnavailable,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
],
..._details(context, content, analysis),
],
),
),
),
);
}
}
/// Everything on the page but the map: what it was and when, where it came
/// from, its totals, weather, notes, sets, steps, splits and charts; what
/// the map [shown] sees behind a question mark (D63).
List<Widget> _details(
BuildContext context,
WorkoutContent content,
WorkoutAnalysis analysis, {
MapSource? shown,
}) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final started = content.startedAt.toLocal();
final hasTrack = analysis.track.isNotEmpty;
return [
Row( Row(
children: [ children: [
Icon(activityIcon(content.activity)), Icon(activityIcon(content.activity)),
@ -230,6 +657,23 @@ class const _Workout({
style: theme.textTheme.bodyLarge, style: theme.textTheme.bodyLarge,
), ),
), ),
if (shown != null)
HelpButton(
shown.tileKey == null
? l10n.workoutMapSource(shown.name)
: l10n.workoutMapOwn(shown.name),
title: l10n.workoutMapAbout,
action: switch (shown.aboutUrl) {
final url? => (
label: l10n.workoutMapSourceAbout(shown.name),
onPressed: () => launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
),
),
null => null,
},
),
], ],
), ),
if (content.origin case final origin?) ...[ if (content.origin case final origin?) ...[
@ -269,11 +713,7 @@ class const _Workout({
], ],
if (content.plan case final plan? when analysis.laps.isNotEmpty) ...[ if (content.plan case final plan? when analysis.laps.isNotEmpty) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
_Laps( _Laps(activity: content.activity, name: plan.name, laps: analysis.laps),
activity: content.activity,
name: plan.name,
laps: analysis.laps,
),
], ],
if (analysis.splits.isNotEmpty) ...[ if (analysis.splits.isNotEmpty) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
@ -355,58 +795,35 @@ class const _Workout({
label: (value) => l10n.workoutClimbValue(value.round()), label: (value) => l10n.workoutClimbValue(value.round()),
), ),
], ],
], ];
);
// The sides kept clear; the foot not, as the page runs on beneath the
// navigation bar floating over it, as the tabs do.
return SafeArea(
top: false,
bottom: false,
child: Column(
children: [
if (map case MapFrom(:final source) when hasTrack)
SizedBox(
height: MediaQuery.sizeOf(context).height * 0.4,
child: _TrackMap(track: analysis.track, source: source),
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: details,
),
),
),
],
),
);
}
} }
/// The track on the source's tiles, a gap at each pause, its start and end /// The track on the source's tiles, a gap at each pause, its start and end
/// marked, the source credited; the camera fits the track. /// marked, the source credited; the camera fits the track, [fit] around it,
/// and moves with [controller]. [creditClear], where given, is how far up
/// a sheet covers the map (of its height), the credit's button kept above
/// it.
class const _TrackMap({ class const _TrackMap({
required final List<List<Fix>> track, required final List<List<LatLng>> pieces,
required final MapSource source, required final MapSource source,
required final EdgeInsets fit,
final MapController? controller,
final ValueListenable<double>? creditClear,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final train = PersonScope.of<TrainPerson>(context); final train = PersonScope.of<TrainPerson>(context);
LatLng at(Fix fix) => LatLng(fix.latE7 / 1e7, fix.lonE7 / 1e7);
final pieces = [
for (final piece in track) [for (final fix in piece) at(fix)],
];
final all = [for (final piece in pieces) ...piece]; final all = [for (final piece in pieces) ...piece];
final (start, end) = (all.first, all.last); final (start, end) = (all.first, all.last);
final credit = mapAttribution(context, source);
return FlutterMap( return FlutterMap(
mapController: controller,
options: MapOptions( options: MapOptions(
maxZoom: mapMaxZoom, maxZoom: mapMaxZoom,
initialCameraFit: all.length > 1 initialCameraFit: all.length > 1
? CameraFit.coordinates( ? CameraFit.coordinates(
coordinates: all, coordinates: all,
padding: const EdgeInsets.all(32), padding: fit,
maxZoom: 17, maxZoom: 17,
) )
: null, : null,
@ -436,11 +853,19 @@ class const _TrackMap({
_marker(end, PaletteColor.cerise.background), _marker(end, PaletteColor.cerise.background),
], ],
), ),
// Folded into a button, as the credit is longer than a phone is if (creditClear case final clear?)
// wide. ValueListenableBuilder(
RichAttributionWidget( valueListenable: clear,
attributions: [TextSourceAttribution(source.attribution)], builder: (context, extent, child) => Padding(
padding: EdgeInsets.only(
bottom: extent * MediaQuery.sizeOf(context).height,
), ),
child: child,
),
child: credit,
)
else
credit,
], ],
); );
} }
@ -459,44 +884,6 @@ class const _TrackMap({
); );
} }
/// What the map's source sees, and the way to its own page about that; or
/// that the map can't be loaded.
class const _MapNote({required final WorkoutMap? map}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final style = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return switch (map) {
null => const SizedBox.shrink(),
MapUnavailable() => Text(l10n.workoutMapUnavailable, style: style),
MapFrom(:final source) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
source.tileKey == null
? l10n.workoutMapSource(source.name)
: l10n.workoutMapOwn(source.name),
style: style,
),
if (source.aboutUrl case final url?)
TextButton.icon(
style: TextButton.styleFrom(padding: EdgeInsets.zero),
icon: const Icon(LucideIcons.externalLink, size: 16),
label: Text(l10n.workoutMapSourceAbout(source.name)),
onPressed: () => launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
),
),
],
),
};
}
}
/// The workout's figures, each with its label beneath. /// The workout's figures, each with its label beneath.
class const _Totals({ class const _Totals({
required final WorkoutContent content, required final WorkoutContent content,

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/data/weather_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.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/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/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.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/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/maps_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart'; import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -289,6 +291,21 @@ void main() {
await tester.pumpAndSettle(); 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). /// Goes to the tab [label] of the navigation bar (ADR 0030, D60).
Future<void> goTab(WidgetTester tester, String label) async { Future<void> goTab(WidgetTester tester, String label) async {
await tester.tap( await tester.tap(
@ -2122,20 +2139,16 @@ void main() {
await pass(tester); await pass(tester);
expect(find.text('Save this workout?'), findsOneWidget); 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 tester.tap(find.text('Save'));
await pass(tester, 2); await pass(tester, 2);
final ride = find.textContaining(RegExp('ride', caseSensitive: false)); final ride = find.textContaining(RegExp('ride', caseSensitive: false));
expect( final page = find.byType(WorkoutScreen);
find.descendant(of: find.byType(AppBar), matching: ride), expect(find.descendant(of: page, matching: ride), findsWidgets);
findsOneWidget, expect(find.byType(FloatingNavigationBar), findsNothing);
);
await tester.pageBack(); await tester.pageBack();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(page, findsNothing);
find.descendant(of: find.byType(AppBar), matching: ride),
findsNothing,
);
expect(ride, findsWidgets); expect(ride, findsWidgets);
expect(startFor(), findsNothing); expect(startFor(), findsNothing);
final saved = (await workouts.all()).single.content!; final saved = (await workouts.all()).single.content!;
@ -2404,7 +2417,9 @@ void main() {
StepKind.recovery, 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 goTab(tester, 'Home');
await tester.tap(find.text('0:50')); await tester.tap(find.text('0:50'));
await tester.runAsync(pumpEventQueue); await tester.runAsync(pumpEventQueue);
@ -3049,13 +3064,16 @@ void main() {
folder.deleteSync(recursive: true); 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( Future<void> openRoutes(
WidgetTester tester, { WidgetTester tester, {
({double lat, double lon})? at, ({double lat, double lon})? at,
({double lat, double lon})? last,
OfflineMaps? offlineMaps, OfflineMaps? offlineMaps,
RouteFiles? routeFiles, RouteFiles? routeFiles,
PlaceSearchGateway? placeSearch, PlaceSearchGateway? placeSearch,
WorkoutRepository? workouts,
}) async { }) async {
await start( await start(
tester, tester,
@ -3065,10 +3083,13 @@ void main() {
placeSearch: placeSearch, placeSearch: placeSearch,
offlineMaps: offlineMaps, offlineMaps: offlineMaps,
routeFiles: routeFiles, routeFiles: routeFiles,
recording: at == null workouts: workouts,
recording: at == null && last == null
? null ? null
: ( : (
platform: FakeRecorderPlatform()..at = at, platform: FakeRecorderPlatform()
..at = at
..lastAt = last,
file: File('${folder.path}/recording.txt'), file: File('${folder.path}/recording.txt'),
devices: { devices: {
for (final kind in DeviceKind.values) for (final kind in DeviceKind.values)
@ -3109,13 +3130,7 @@ void main() {
); );
await tester.tap(find.text('New route')); await tester.tap(find.text('New route'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( await expectRouteHelp(tester);
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 tester.enterText(find.byType(TextField).first, 'Tempelhof'); await tester.enterText(find.byType(TextField).first, 'Tempelhof');
// Past the pause after typing. // Past the pause after typing.
@ -3154,13 +3169,7 @@ void main() {
await tester.tap(find.text('New route')); await tester.tap(find.text('New route'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget); expect(find.text('1'), findsOneWidget);
expect( await expectRouteHelp(tester);
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 tapMap(tester, const Offset(60, 40)); await tapMap(tester, const Offset(60, 40));
expect( expect(
@ -3203,6 +3212,82 @@ void main() {
expect(route.content?.planned, isNotNull); 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', ( testWidgets('the activity picks the profile, which plans the way again', (
tester, tester,
) async { ) async {
@ -3758,8 +3843,9 @@ void main() {
); );
}); });
testWidgets('home and a workout’s page run on beneath the floating bar, ' testWidgets('home runs on beneath the floating bar, its list ending '
'their lists ending clear of it', (tester) async { "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++) { for (var day = 1; day <= 12; day++) {
await saveRun(DateTime(2026, 10, day, 7, 30)); await saveRun(DateTime(2026, 10, day, 7, 30));
} }
@ -3790,7 +3876,21 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Morning run').first); await tester.tap(find.text('Morning run').first);
await tester.pumpAndSettle(); 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 ' 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('2.50 km'), findsOneWidget);
expect(find.text('20 m'), findsOneWidget); expect(find.text('20 m'), findsOneWidget);
expect(find.text('whole time'), findsOneWidget); expect(find.text('whole time'), findsOneWidget);
expect( // What the map's source sees, one tap away (D63).
find.text( const sees =
"Map by OpenFreeMap: it sees your device's address and the area " "Map by OpenFreeMap: it sees your device's address and the area "
'shown, not your track.', 'shown, not your track.';
), expect(find.text(sees), findsNothing);
findsOneWidget, 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); expect(find.text('What OpenFreeMap keeps'), findsOneWidget);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('km 3'), 200); await tester.scrollUntilVisible(find.text('km 3'), 200);
expect(find.text('Splits'), findsOneWidget); expect(find.text('Splits'), findsOneWidget);
expect(find.text('km 1'), findsOneWidget); expect(find.text('km 1'), findsOneWidget);
@ -3925,6 +4029,8 @@ void main() {
await tester.tap(find.text('Morning run')); await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip("What's this?"));
await tester.pumpAndSettle();
expect( expect(
find.text( find.text(
@ -3936,6 +4042,115 @@ void main() {
expect(find.textContaining('sees your device'), findsNothing); 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 " testWidgets("a workout with a strap shows the heart's average and highest "
'rate, by split too, and its chart', (tester) async { 'rate, by split too, and its chart', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30), strap: true); 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