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 })
|
||||
"isRecording" -> result.success(RecorderService.isRecording())
|
||||
"position" -> position(result)
|
||||
"lastPosition" -> result.success(lastPosition())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
@ -279,6 +280,22 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Where the phone was last known to be by any provider, the newest; none without the
|
||||
* location permission, which this never asks for (ADR 0030, D63: a new route's map opens
|
||||
* there at once). */
|
||||
private fun lastPosition(): Map<String, Double>? {
|
||||
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
|
||||
checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val manager = getSystemService(LocationManager::class.java)
|
||||
val newest = manager.getProviders(true)
|
||||
.mapNotNull { manager.getLastKnownLocation(it) }
|
||||
.maxByOrNull { it.time }
|
||||
return newest?.let { mapOf("lat" to it.latitude, "lon" to it.longitude) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RECORDER_REQUEST = 1
|
||||
const val FRESH_MS = 120_000L
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@
|
|||
"workoutMapOwn": "Karte von deiner Instanz, {name}, aus OpenStreetMap: sie speichert nichts vom gezeigten Ausschnitt.",
|
||||
"workoutMapSourceAbout": "Was {name} speichert",
|
||||
"workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.",
|
||||
"workoutMapAbout": "Über die Karte",
|
||||
"workoutShowMap": "Karte zeigen",
|
||||
"mapCredits": "Kartennachweise",
|
||||
"routeHelpTitle": "Eine Route planen",
|
||||
"workoutNotFound": "Dieses Training gibt es nicht mehr.",
|
||||
|
||||
"recordHeartRate": "Herzfrequenz",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,14 @@
|
|||
"workoutMapSourceAbout": "What {name} keeps",
|
||||
"@workoutMapSourceAbout": {"description": "A link to the map source's page about what it keeps.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}},
|
||||
"workoutMapUnavailable": "The map can't be loaded now.",
|
||||
"workoutMapAbout": "About the map",
|
||||
"@workoutMapAbout": {"description": "Title of the explanation behind the question mark on a workout's page (D63): what the map's source sees."},
|
||||
"workoutShowMap": "Show the map",
|
||||
"@workoutShowMap": {"description": "Tooltip of the chevron that lowers a workout's sheet, the page pulled up, so that the map shows again (D63)."},
|
||||
"mapCredits": "Map credits",
|
||||
"@mapCredits": {"description": "Tooltip of the button over a map that shows whose map and data it is."},
|
||||
"routeHelpTitle": "Planning a route",
|
||||
"@routeHelpTitle": {"description": "Title of the explanation behind the question mark over the route planner's map (D63)."},
|
||||
"workoutNotFound": "This workout is gone.",
|
||||
|
||||
"recordHeartRate": "Heart rate",
|
||||
|
|
|
|||
|
|
@ -622,6 +622,30 @@ abstract class AppLocalizations {
|
|||
/// **'The map can\'t be loaded now.'**
|
||||
String get workoutMapUnavailable;
|
||||
|
||||
/// Title of the explanation behind the question mark on a workout's page (D63): what the map's source sees.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About the map'**
|
||||
String get workoutMapAbout;
|
||||
|
||||
/// Tooltip of the chevron that lowers a workout's sheet, the page pulled up, so that the map shows again (D63).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show the map'**
|
||||
String get workoutShowMap;
|
||||
|
||||
/// Tooltip of the button over a map that shows whose map and data it is.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Map credits'**
|
||||
String get mapCredits;
|
||||
|
||||
/// Title of the explanation behind the question mark over the route planner's map (D63).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Planning a route'**
|
||||
String get routeHelpTitle;
|
||||
|
||||
/// No description provided for @workoutNotFound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -344,6 +344,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get workoutMapUnavailable =>
|
||||
'Die Karte kann gerade nicht geladen werden.';
|
||||
|
||||
@override
|
||||
String get workoutMapAbout => 'Über die Karte';
|
||||
|
||||
@override
|
||||
String get workoutShowMap => 'Karte zeigen';
|
||||
|
||||
@override
|
||||
String get mapCredits => 'Kartennachweise';
|
||||
|
||||
@override
|
||||
String get routeHelpTitle => 'Eine Route planen';
|
||||
|
||||
@override
|
||||
String get workoutNotFound => 'Dieses Training gibt es nicht mehr.';
|
||||
|
||||
|
|
|
|||
|
|
@ -341,6 +341,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get workoutMapUnavailable => 'The map can\'t be loaded now.';
|
||||
|
||||
@override
|
||||
String get workoutMapAbout => 'About the map';
|
||||
|
||||
@override
|
||||
String get workoutShowMap => 'Show the map';
|
||||
|
||||
@override
|
||||
String get mapCredits => 'Map credits';
|
||||
|
||||
@override
|
||||
String get routeHelpTitle => 'Planning a route';
|
||||
|
||||
@override
|
||||
String get workoutNotFound => 'This workout is gone.';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
|
||||
|
||||
/// The app's locations (ADR 0030, D60): the five tabs, a workout's page in
|
||||
/// Home and a day's in Profile, and the editors and the recording over the
|
||||
/// tabs, and Settings.
|
||||
/// The app's locations (ADR 0030, D60): the five tabs, a day's page in
|
||||
/// Profile, and a workout's page (D63), the editors and the recording over
|
||||
/// the tabs, and Settings.
|
||||
abstract final class TrainPaths() {
|
||||
/// Home, where the app opens: the workouts.
|
||||
static const home = '/';
|
||||
|
|
@ -19,7 +19,7 @@ abstract final class TrainPaths() {
|
|||
/// Profile: the days.
|
||||
static const profile = '/profile';
|
||||
|
||||
/// A workout's page, in Home.
|
||||
/// A workout's page, in Home, over the tabs (D63).
|
||||
static String workout(String id) => '/workouts/$id';
|
||||
|
||||
/// The page of the day [date], in Profile, by the band's name for it.
|
||||
|
|
|
|||
|
|
@ -16,14 +16,19 @@ import 'package:halkyon_train_flutter/src/tabs/profile_tab.dart';
|
|||
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
|
||||
import 'package:halkyon_train_flutter/src/tabs/train_shell.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The router of one person's app (ADR 0030, D60), as Learn's: the five tabs
|
||||
/// in a shell, each with its own stack of screens, and the editors and the
|
||||
/// recording over them. Every route is built inside the person's scope, so
|
||||
/// in a shell, each with its own stack of screens, and the editors, the
|
||||
/// recording and a workout's page, in Home, over them (D63). Every route is built inside the person's scope, so
|
||||
/// screens read their repositories from `PersonScope`. A route or plan is
|
||||
/// handed to its editor as it is (`extra`): a new one exists nowhere else
|
||||
/// yet.
|
||||
GoRouter buildTrainRouter() => GoRouter(
|
||||
GoRouter buildTrainRouter() => _router(GlobalKey());
|
||||
|
||||
/// The router, its navigator over the shell [root].
|
||||
GoRouter _router(GlobalKey<NavigatorState> root) => GoRouter(
|
||||
navigatorKey: root,
|
||||
routes: [
|
||||
StatefulShellRoute(
|
||||
builder: (context, state, shell) => TrainShell(shell: shell),
|
||||
|
|
@ -38,8 +43,11 @@ GoRouter buildTrainRouter() => GoRouter(
|
|||
path: TrainPaths.home,
|
||||
builder: (context, state) => const HomeTab(),
|
||||
routes: [
|
||||
// In Home, so that back leads there, but over the shell,
|
||||
// its map to the foot of the screen (D63).
|
||||
GoRoute(
|
||||
path: 'workouts/:id',
|
||||
parentNavigatorKey: root,
|
||||
builder: (context, state) =>
|
||||
workoutPage(context, state.pathParameters['id']!),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -21,3 +21,39 @@ Map<String, String>? tileHeadersOf(MapSource source) =>
|
|||
final key? => {'authorization': 'Bearer $key'},
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// [style] (a MapLibre style's JSON) naming places in [language] (ADR 0030,
|
||||
/// D63): where its labels ask for the English name (`name_en`, as
|
||||
/// OpenFreeMap's styles do), the name in [language] (OpenMapTiles'
|
||||
/// `name:<language>`) instead, the name on the spot where there is none, as
|
||||
/// before. English, and a style asking for no English name (the instance's
|
||||
/// own, named in its language by its server, D50), stay as they are.
|
||||
Map<String, Object?> labelledIn(Map<String, Object?> style, String language) {
|
||||
if (language == 'en') {
|
||||
return style;
|
||||
}
|
||||
final name = 'name:$language';
|
||||
Object? relabel(Object? value) => switch (value) {
|
||||
'name_en' => name,
|
||||
final String text => text.replaceAll('{name_en}', '{$name}'),
|
||||
final List<Object?> list => [for (final item in list) relabel(item)],
|
||||
final Map<String, Object?> map => {
|
||||
for (final MapEntry(:key, value: item) in map.entries) key: relabel(item),
|
||||
},
|
||||
_ => value,
|
||||
};
|
||||
return {
|
||||
...style,
|
||||
if (style['layers'] case final List<Object?> layers)
|
||||
'layers': [
|
||||
for (final layer in layers)
|
||||
switch (layer) {
|
||||
{'layout': final Map<String, Object?> layout} => {
|
||||
...layer as Map<String, Object?>,
|
||||
'layout': relabel(layout),
|
||||
},
|
||||
_ => layer,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/data/map_style.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:vector_map_tiles/vector_map_tiles.dart';
|
||||
import 'package:vector_tile_renderer/vector_tile_renderer.dart'
|
||||
show SpriteIndexReader, ThemeReader;
|
||||
|
|
@ -52,7 +55,8 @@ Future<Style> _keptStyle(
|
|||
final sprite = style.spriteIndex;
|
||||
final image = style.spriteImage;
|
||||
return Style(
|
||||
theme: ThemeReader().read(style.json),
|
||||
// Its places named in the app's language (D63).
|
||||
theme: ThemeReader().read(labelledIn(style.json, language)),
|
||||
providers: TileProviders({
|
||||
for (final MapEntry(:key, :value) in style.sources.entries)
|
||||
key: _KeptFirst(maps, key, value, tileHeadersOf(source)),
|
||||
|
|
@ -137,3 +141,32 @@ class _VectorTilesState() extends State<_VectorTiles> {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// [source]'s credit over a map, folded into a button, as it is longer than
|
||||
/// a phone is wide; open, on the theme's surface in the theme's colours,
|
||||
/// readable in either (ADR 0030, D63: flutter_map's own, built on
|
||||
/// `flutter/material`, never sees the app's theme, which is `material_ui`'s,
|
||||
/// and put the theme's text on the fallback theme's white).
|
||||
Widget mapAttribution(BuildContext context, MapSource source) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final text = TextStyle(color: scheme.onSurface);
|
||||
return DefaultTextStyle.merge(
|
||||
style: text,
|
||||
child: RichAttributionWidget(
|
||||
popupBackgroundColor: scheme.surfaceContainerHigh,
|
||||
openButton: (context, open) => IconButton.filledTonal(
|
||||
tooltip: AppLocalizations.of(context).mapCredits,
|
||||
icon: const Icon(LucideIcons.info),
|
||||
onPressed: open,
|
||||
),
|
||||
closeButton: (context, close) => IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: Icon(LucideIcons.x, color: scheme.onSurface),
|
||||
onPressed: close,
|
||||
),
|
||||
attributions: [
|
||||
TextSourceAttribution(source.attribution, textStyle: text),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,13 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<({double lat, double lon})?> lastPosition() async =>
|
||||
switch (await _channel.invokeMapMethod<String, double>('lastPosition')) {
|
||||
{'lat': final lat, 'lon': final lon} => (lat: lat, lon: lon),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,12 @@ abstract interface class RecorderPlatform() {
|
|||
/// the location permission ([ask] first) or a fix within 15 s.
|
||||
Future<({double lat, double lon})?> position();
|
||||
|
||||
/// Where the phone was last known to be, by any of its ways of telling,
|
||||
/// at once and however long ago; null without the location permission
|
||||
/// (never asked for here) or where none is known (ADR 0030, D63: a new
|
||||
/// route's map opens there while [position] waits for a fix).
|
||||
Future<({double lat, double lon})?> lastPosition();
|
||||
|
||||
/// Every line of the file written so far, then each new one as it is
|
||||
/// written, while a recording runs.
|
||||
Stream<String> lines();
|
||||
|
|
|
|||
|
|
@ -586,9 +586,7 @@ class _RecordingMapState() extends State<_RecordingMap> {
|
|||
],
|
||||
),
|
||||
if (snapshot.data case final source?)
|
||||
RichAttributionWidget(
|
||||
attributions: [TextSourceAttribution(source.attribution)],
|
||||
),
|
||||
mapAttribution(context, source),
|
||||
],
|
||||
),
|
||||
if (live.route case final route?)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
|
@ -21,6 +23,8 @@ import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart
|
|||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart'
|
||||
show ChartPoint;
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_start.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
|
||||
|
|
@ -66,10 +70,29 @@ Widget routePage(BuildContext context, RouteContent route, {String? id}) {
|
|||
create: (context) => PlaceSearchCubit(train.placeSearch)..open(),
|
||||
),
|
||||
],
|
||||
child: RouteScreen(maps: train.maps.source()),
|
||||
child: RouteScreen(
|
||||
maps: train.maps.source(),
|
||||
// Only a new route's map opens near the person; a stored one fits it.
|
||||
near: route.waypoints.isEmpty
|
||||
? _near(platform, train.workouts)
|
||||
: Future.value(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Where a new route's map opens at once (ADR 0030, D63): where the phone
|
||||
/// was last known to be, else where the newest workout with a track
|
||||
/// started; null where neither is known, the map then showing the region.
|
||||
Future<LatLng?> _near(
|
||||
RecorderPlatform? platform,
|
||||
WorkoutRepository workouts,
|
||||
) async {
|
||||
if (await platform?.lastPosition() case (:final lat, :final lon)) {
|
||||
return LatLng(lat, lon);
|
||||
}
|
||||
return await lastWorkoutStart(workouts);
|
||||
}
|
||||
|
||||
/// Where the phone is, the location permission asked for first.
|
||||
Future<LatLng?> _here(RecorderPlatform platform) async {
|
||||
if (!(await platform.ask({
|
||||
|
|
@ -89,8 +112,11 @@ Future<LatLng?> _here(RecorderPlatform platform) async {
|
|||
/// under it the route's name, what it is for and its profile, and the way
|
||||
/// planned: its distance, climb and descent, its elevation and its
|
||||
/// surfaces. Saved with the button, deleted after asking.
|
||||
class const RouteScreen({required final Future<MapSource?> maps, super.key})
|
||||
extends StatelessWidget {
|
||||
class const RouteScreen({
|
||||
required final Future<MapSource?> maps,
|
||||
final Future<LatLng?>? near,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -130,24 +156,37 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
|
|||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: FutureBuilder(
|
||||
future: maps,
|
||||
builder: (context, snapshot) => switch (snapshot) {
|
||||
AsyncSnapshot(:final MapSource data) => _RouteMap(
|
||||
source: data,
|
||||
),
|
||||
AsyncSnapshot(connectionState: ConnectionState.done) =>
|
||||
Center(child: Text(l10n.workoutMapUnavailable)),
|
||||
_ => const Center(child: AppLoadingIndicator()),
|
||||
},
|
||||
),
|
||||
),
|
||||
const Expanded(flex: 2, child: _Details()),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final map = FutureBuilder(
|
||||
future: maps,
|
||||
builder: (context, snapshot) => switch (snapshot) {
|
||||
AsyncSnapshot(:final MapSource data) => _RouteMap(
|
||||
source: data,
|
||||
near: near,
|
||||
),
|
||||
AsyncSnapshot(connectionState: ConnectionState.done) =>
|
||||
Center(child: Text(l10n.workoutMapUnavailable)),
|
||||
_ => const Center(child: AppLoadingIndicator()),
|
||||
},
|
||||
);
|
||||
// From expanded windows on, the details beside the map, as
|
||||
// Material's supporting pane (D63).
|
||||
return context.windowSize.isAtLeastExpanded
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: map),
|
||||
const SizedBox(width: 400, child: _Details()),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(flex: 3, child: map),
|
||||
const Expanded(flex: 2, child: _Details()),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -197,8 +236,10 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
|
|||
/// offers to take it off, the button above takes the last off, and a place searched for and chosen is the
|
||||
/// next (D50). The camera fits the route as it opens, and
|
||||
/// goes to a new route's first waypoint once it is known.
|
||||
class const _RouteMap({required final MapSource source})
|
||||
extends StatefulWidget {
|
||||
class const _RouteMap({
|
||||
required final MapSource source,
|
||||
final Future<LatLng?>? near,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_RouteMap> createState() => _RouteMapState();
|
||||
}
|
||||
|
|
@ -220,11 +261,38 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
/// What the camera fits as the map opens: the route as it was opened.
|
||||
late final List<LatLng> _fit;
|
||||
|
||||
/// Whether the map is drawn, so that its camera can be moved.
|
||||
var _ready = false;
|
||||
|
||||
/// Where a new route's map goes as it opens, once known (D63).
|
||||
LatLng? _near;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final route = context.read<RouteEditorCubit>().state.route;
|
||||
_fit = [...?route.planned?.points, ...route.waypoints];
|
||||
unawaited(
|
||||
widget.near?.then((near) {
|
||||
if (mounted) {
|
||||
_near = near;
|
||||
_goNear();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Closer, near the person, while the map still shows the region and no
|
||||
/// waypoint is set: zoom 14, a town's district, as a map app opens on
|
||||
/// where one is.
|
||||
void _goNear() {
|
||||
final near = _near;
|
||||
if (_ready &&
|
||||
near != null &&
|
||||
context.read<RouteEditorCubit>().state.route.waypoints.isEmpty &&
|
||||
_map.camera.zoom < 10) {
|
||||
_map.move(near, 14);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -251,9 +319,6 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
},
|
||||
builder: (context, state) {
|
||||
final route = state.route;
|
||||
final searches = context.select<PlaceSearchCubit, bool>(
|
||||
(search) => search.state.offered ?? false,
|
||||
);
|
||||
final waypoints = [
|
||||
for (final (index, point) in route.waypoints.indexed) ...[
|
||||
if (_pulled case (final pulled, final at) when pulled == index) at,
|
||||
|
|
@ -283,6 +348,10 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
|
||||
),
|
||||
onTap: (_, point) => cubit().add(point),
|
||||
onMapReady: () {
|
||||
_ready = true;
|
||||
_goNear();
|
||||
},
|
||||
),
|
||||
children: [
|
||||
train.mapTiles(widget.source),
|
||||
|
|
@ -339,11 +408,7 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
),
|
||||
],
|
||||
),
|
||||
RichAttributionWidget(
|
||||
attributions: [
|
||||
TextSourceAttribution(widget.source.attribution),
|
||||
],
|
||||
),
|
||||
mapAttribution(context, widget.source),
|
||||
],
|
||||
),
|
||||
if (state.planning)
|
||||
|
|
@ -356,27 +421,31 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: l10n.routeUndo,
|
||||
icon: const Icon(LucideIcons.undo2),
|
||||
onPressed: route.waypoints.isEmpty
|
||||
? null
|
||||
: () => cubit().undo(),
|
||||
child: Column(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.routeUndo,
|
||||
icon: const Icon(LucideIcons.undo2),
|
||||
onPressed: route.waypoints.isEmpty
|
||||
? null
|
||||
: () => cubit().undo(),
|
||||
),
|
||||
// How the map is used, one tap away rather than over it
|
||||
// (D63).
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
child: HelpButton(
|
||||
l10n.routeTapHint,
|
||||
title: l10n.routeHelpTitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (route.waypoints.length < 2 && state.offered != false)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 64,
|
||||
top: searches ? 72 : 12,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(l10n.routeTapHint),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Last, so that the places found lie over the hint.
|
||||
// Last, so that the places found lie over the map's buttons.
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 64,
|
||||
|
|
|
|||
|
|
@ -97,10 +97,7 @@ class _RoutesMapState() extends State<RoutesMap> {
|
|||
],
|
||||
),
|
||||
),
|
||||
if (snapshot.data case final source?)
|
||||
RichAttributionWidget(
|
||||
attributions: [TextSourceAttribution(source.attribution)],
|
||||
),
|
||||
if (snapshot.data case final source?) mapAttribution(context, source),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -56,9 +56,11 @@ import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.d
|
|||
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/week_card.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/tabs/maps_tab.dart';
|
||||
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -289,6 +291,21 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The route planner's help, behind its question mark rather than over
|
||||
/// the map (D63): opened, read and closed.
|
||||
Future<void> expectRouteHelp(WidgetTester tester) async {
|
||||
const help =
|
||||
'Tap the map to add a waypoint, tap one to take it off. Hold '
|
||||
'one to drag it, or hold the line to pull out another.';
|
||||
expect(find.text(help), findsNothing);
|
||||
await tester.tap(find.byTooltip("What's this?"));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Planning a route'), findsOneWidget);
|
||||
expect(find.text(help), findsOneWidget);
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Goes to the tab [label] of the navigation bar (ADR 0030, D60).
|
||||
Future<void> goTab(WidgetTester tester, String label) async {
|
||||
await tester.tap(
|
||||
|
|
@ -2122,20 +2139,16 @@ void main() {
|
|||
await pass(tester);
|
||||
expect(find.text('Save this workout?'), findsOneWidget);
|
||||
|
||||
// Saved, its page opens, in Home's tab (D60).
|
||||
// Saved, its page opens, over the tabs (D63).
|
||||
await tester.tap(find.text('Save'));
|
||||
await pass(tester, 2);
|
||||
final ride = find.textContaining(RegExp('ride', caseSensitive: false));
|
||||
expect(
|
||||
find.descendant(of: find.byType(AppBar), matching: ride),
|
||||
findsOneWidget,
|
||||
);
|
||||
final page = find.byType(WorkoutScreen);
|
||||
expect(find.descendant(of: page, matching: ride), findsWidgets);
|
||||
expect(find.byType(FloatingNavigationBar), findsNothing);
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(of: find.byType(AppBar), matching: ride),
|
||||
findsNothing,
|
||||
);
|
||||
expect(page, findsNothing);
|
||||
expect(ride, findsWidgets);
|
||||
expect(startFor(), findsNothing);
|
||||
final saved = (await workouts.all()).single.content!;
|
||||
|
|
@ -2404,7 +2417,9 @@ void main() {
|
|||
StepKind.recovery,
|
||||
]);
|
||||
|
||||
// The workout, in Home.
|
||||
// The workout, over the tabs (D63), and again from Home.
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
await goTab(tester, 'Home');
|
||||
await tester.tap(find.text('0:50'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
|
|
@ -3049,13 +3064,16 @@ void main() {
|
|||
folder.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
/// Maps, on a phone at [at] (null: a device that can't tell).
|
||||
/// Maps, on a phone at [at] (null: a device that can't tell), last known
|
||||
/// at [last], with [workouts].
|
||||
Future<void> openRoutes(
|
||||
WidgetTester tester, {
|
||||
({double lat, double lon})? at,
|
||||
({double lat, double lon})? last,
|
||||
OfflineMaps? offlineMaps,
|
||||
RouteFiles? routeFiles,
|
||||
PlaceSearchGateway? placeSearch,
|
||||
WorkoutRepository? workouts,
|
||||
}) async {
|
||||
await start(
|
||||
tester,
|
||||
|
|
@ -3065,10 +3083,13 @@ void main() {
|
|||
placeSearch: placeSearch,
|
||||
offlineMaps: offlineMaps,
|
||||
routeFiles: routeFiles,
|
||||
recording: at == null
|
||||
workouts: workouts,
|
||||
recording: at == null && last == null
|
||||
? null
|
||||
: (
|
||||
platform: FakeRecorderPlatform()..at = at,
|
||||
platform: FakeRecorderPlatform()
|
||||
..at = at
|
||||
..lastAt = last,
|
||||
file: File('${folder.path}/recording.txt'),
|
||||
devices: {
|
||||
for (final kind in DeviceKind.values)
|
||||
|
|
@ -3109,13 +3130,7 @@ void main() {
|
|||
);
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'Tap the map to add a waypoint, tap one to take it off. Hold '
|
||||
'one to drag it, or hold the line to pull out another.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await expectRouteHelp(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Tempelhof');
|
||||
// Past the pause after typing.
|
||||
|
|
@ -3154,13 +3169,7 @@ void main() {
|
|||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Tap the map to add a waypoint, tap one to take it off. Hold '
|
||||
'one to drag it, or hold the line to pull out another.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await expectRouteHelp(tester);
|
||||
|
||||
await tapMap(tester, const Offset(60, 40));
|
||||
expect(
|
||||
|
|
@ -3203,6 +3212,82 @@ void main() {
|
|||
expect(route.content?.planned, isNotNull);
|
||||
});
|
||||
|
||||
group("a new route's map opens near the person (D63)", () {
|
||||
/// The planner's camera.
|
||||
MapCamera camera(WidgetTester tester) => MapCamera.of(
|
||||
tester.element(
|
||||
find.descendant(
|
||||
of: find.byType(FlutterMap),
|
||||
matching: find.byType(MarkerLayer),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('where the phone was last known, closer, before any fix', (
|
||||
tester,
|
||||
) async {
|
||||
await openRoutes(tester, last: (lat: 52.52, lon: 13.405));
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(camera(tester).zoom, 14);
|
||||
expect(camera(tester).center.latitude, closeTo(52.52, 1e-6));
|
||||
expect(find.text('1'), findsNothing, reason: 'no waypoint set');
|
||||
});
|
||||
|
||||
testWidgets('else where the newest workout with a track started', (
|
||||
tester,
|
||||
) async {
|
||||
final workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
|
||||
final start = DateTime.utc(2026, 10, 20, 7);
|
||||
await workouts.save(
|
||||
WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
endedAt: start.add(const Duration(minutes: 20)),
|
||||
),
|
||||
chunksOf(
|
||||
SeriesKind.position,
|
||||
start: start,
|
||||
times: const [0],
|
||||
columns: {
|
||||
'lat': [475500000],
|
||||
'lon': [96800000],
|
||||
},
|
||||
),
|
||||
);
|
||||
await openRoutes(tester, workouts: workouts);
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(camera(tester).zoom, 14);
|
||||
expect(camera(tester).center.latitude, closeTo(47.55, 1e-6));
|
||||
expect(camera(tester).center.longitude, closeTo(9.68, 1e-6));
|
||||
});
|
||||
|
||||
testWidgets('else the region, as before', (tester) async {
|
||||
await openRoutes(tester);
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(camera(tester).zoom, lessThan(10));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("from expanded windows on, the planner's details beside its "
|
||||
'map (D63)', (tester) async {
|
||||
await openRoutes(tester);
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.getTopRight(find.byType(FlutterMap)).dx,
|
||||
lessThanOrEqualTo(tester.getTopLeft(find.text('Name')).dx),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the activity picks the profile, which plans the way again', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -3758,8 +3843,9 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('home and a workout’s page run on beneath the floating bar, '
|
||||
'their lists ending clear of it', (tester) async {
|
||||
testWidgets('home runs on beneath the floating bar, its list ending '
|
||||
"clear of it; a workout's page covers the bar, its sheet ending clear "
|
||||
"of the screen's foot (D63)", (tester) async {
|
||||
for (var day = 1; day <= 12; day++) {
|
||||
await saveRun(DateTime(2026, 10, day, 7, 30));
|
||||
}
|
||||
|
|
@ -3790,7 +3876,21 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Morning run').first);
|
||||
await tester.pumpAndSettle();
|
||||
await endsClearOfTheBar(find.byType(Scrollable).last);
|
||||
|
||||
expect(bar, findsNothing);
|
||||
final sheet = find.descendant(
|
||||
of: find.byType(DraggableScrollableSheet),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.drag(sheet, const Offset(0, -5000));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
final items = find.descendant(of: sheet, matching: find.byType(Text));
|
||||
expect(
|
||||
tester.getBottomLeft(items.last).dy,
|
||||
lessThanOrEqualTo(screen - 48),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('above them, this week: its time, distance and workouts, and '
|
||||
|
|
@ -3891,14 +3991,18 @@ void main() {
|
|||
expect(find.text('2.50 km'), findsOneWidget);
|
||||
expect(find.text('20 m'), findsOneWidget);
|
||||
expect(find.text('whole time'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
// What the map's source sees, one tap away (D63).
|
||||
const sees =
|
||||
"Map by OpenFreeMap: it sees your device's address and the area "
|
||||
'shown, not your track.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
'shown, not your track.';
|
||||
expect(find.text(sees), findsNothing);
|
||||
await tester.tap(find.byTooltip("What's this?"));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('About the map'), findsOneWidget);
|
||||
expect(find.text(sees), findsOneWidget);
|
||||
expect(find.text('What OpenFreeMap keeps'), findsOneWidget);
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(find.text('km 3'), 200);
|
||||
expect(find.text('Splits'), findsOneWidget);
|
||||
expect(find.text('km 1'), findsOneWidget);
|
||||
|
|
@ -3925,6 +4029,8 @@ void main() {
|
|||
|
||||
await tester.tap(find.text('Morning run'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip("What's this?"));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
|
|
@ -3936,6 +4042,115 @@ void main() {
|
|||
expect(find.textContaining('sees your device'), findsNothing);
|
||||
});
|
||||
|
||||
group("a workout's page as Strava's (D63)", () {
|
||||
MapCamera camera(WidgetTester tester) =>
|
||||
MapCamera.of(tester.element(find.byType(PolylineLayer)));
|
||||
|
||||
Future<void> openRun(WidgetTester tester) async {
|
||||
await saveRun(DateTime(2026, 10, 27, 7, 30));
|
||||
await startWith(tester);
|
||||
await tester.tap(find.text('Morning run'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('its map to the foot of the screen, under a sheet with its '
|
||||
'name; pulled down, the map fits the track anew, closer', (
|
||||
tester,
|
||||
) async {
|
||||
await openRun(tester);
|
||||
expect(
|
||||
tester.getBottomLeft(find.byType(FlutterMap)).dy,
|
||||
tester.view.physicalSize.height,
|
||||
);
|
||||
final sheet = find.byType(DraggableScrollableSheet);
|
||||
expect(
|
||||
find.descendant(of: sheet, matching: find.text('Morning run')),
|
||||
findsOneWidget,
|
||||
);
|
||||
final before = camera(tester);
|
||||
|
||||
await tester.drag(find.text('2.50 km'), const Offset(0, 400));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(camera(tester).zoom, greaterThan(before.zoom));
|
||||
expect(
|
||||
camera(tester).center.latitude,
|
||||
greaterThan(before.center.latitude),
|
||||
reason:
|
||||
'the track lower on the larger map: the map looks south of it',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("pulled up, the round back button turns into a bar's "
|
||||
'chevron that lowers the sheet again', (tester) async {
|
||||
await openRun(tester);
|
||||
expect(find.byTooltip('Back'), findsOneWidget);
|
||||
expect(find.byTooltip('Show the map'), findsNothing);
|
||||
|
||||
await tester.drag(find.text('2.50 km'), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Show the map'), findsOneWidget);
|
||||
expect(find.byTooltip('Back'), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Show the map'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Back'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Back'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(WorkoutScreen), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('from expanded windows on, the map beside the rest', (
|
||||
tester,
|
||||
) async {
|
||||
await saveRun(DateTime(2026, 10, 27, 7, 30));
|
||||
await startWith(tester);
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Morning run').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(DraggableScrollableSheet), findsNothing);
|
||||
expect(
|
||||
tester.getTopRight(find.byType(FlutterMap)).dx,
|
||||
lessThanOrEqualTo(tester.getTopLeft(find.text('2.50 km')).dx),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("the map's credit opens on the theme's surface, in its "
|
||||
'colours', (tester) async {
|
||||
await openRun(tester);
|
||||
final credit = tester.widget<RichAttributionWidget>(
|
||||
find.byType(RichAttributionWidget),
|
||||
);
|
||||
final scheme = Theme.of(
|
||||
tester.element(find.byType(RichAttributionWidget)),
|
||||
).colorScheme;
|
||||
expect(credit.popupBackgroundColor, scheme.surfaceContainerHigh);
|
||||
|
||||
await tester.tap(find.byTooltip('Map credits'));
|
||||
await tester.pumpAndSettle();
|
||||
final text = find.textContaining('OpenFreeMap © OpenMapTiles');
|
||||
expect(text, findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(TextSourceAttribution),
|
||||
matching: find.byType(Text),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.style
|
||||
?.color,
|
||||
scheme.onSurface,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("a workout with a strap shows the heart's average and highest "
|
||||
'rate, by split too, and its chart', (tester) async {
|
||||
await saveRun(DateTime(2026, 10, 27, 7, 30), strap: true);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue