feat(train): a workout's page as Strava's, maps readable and in the app's language, routes opening near the person (ADR 0030, D63)
- A workout with a track: its map to the foot of the screen under a sheet with its name; once the sheet settles, the map fits the track anew, eased, into what is left free; pulled up, the round buttons over the map fold into an app bar whose chevron lowers the sheet again. From expanded windows on, the map beside the rest. The page stays in Home but opens over the shell, without the floating bar. - What the map's source sees moves behind a question mark. - The map's credit on the app's theme: flutter_map's, built on flutter/material, never saw it (material_ui's Theme is a class of its own) and drew the theme's text on white; map_tiles read that Theme too, and so never chose the instance's dark map (D50). - Places named in the app's language (name:<language>) where OpenFreeMap's styles ask for the English name. - The route planner's hint behind a question mark; a new route's map opens at zoom 14 where the phone was last known to be, else where the newest workout with a track started; its details beside the map from expanded windows on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
30dd8c96eb
commit
3e90753b08
24 changed files with 1518 additions and 358 deletions
|
|
@ -109,6 +109,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +280,22 @@ class MainActivity : FlutterActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where the phone was last known to be by any provider, the newest; none without the
|
||||||
|
* location permission, which this never asks for (ADR 0030, D63: a new route's map opens
|
||||||
|
* there at once). */
|
||||||
|
private fun lastPosition(): Map<String, Double>? {
|
||||||
|
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
|
||||||
|
checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val manager = getSystemService(LocationManager::class.java)
|
||||||
|
val newest = manager.getProviders(true)
|
||||||
|
.mapNotNull { manager.getLastKnownLocation(it) }
|
||||||
|
.maxByOrNull { it.time }
|
||||||
|
return newest?.let { mapOf("lat" to it.latitude, "lon" to it.longitude) }
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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']!),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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?)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue