Merge branch 'train-maps' into integration
This commit is contained in:
commit
10744bcd56
24 changed files with 1588 additions and 360 deletions
|
|
@ -110,6 +110,7 @@ class MainActivity : FlutterActivity() {
|
|||
"show" -> result.success(RecorderService.show(call.arguments as String).let { null })
|
||||
"isRecording" -> result.success(RecorderService.isRecording())
|
||||
"position" -> position(result)
|
||||
"lastPosition" -> result.success(lastPosition())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
@ -280,6 +281,22 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Where the phone was last known to be by any provider, the newest; none without the
|
||||
* location permission, which this never asks for (ADR 0030, D63: a new route's map opens
|
||||
* there at once). */
|
||||
private fun lastPosition(): Map<String, Double>? {
|
||||
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
|
||||
checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val manager = getSystemService(LocationManager::class.java)
|
||||
val newest = manager.getProviders(true)
|
||||
.mapNotNull { manager.getLastKnownLocation(it) }
|
||||
.maxByOrNull { it.time }
|
||||
return newest?.let { mapOf("lat" to it.latitude, "lon" to it.longitude) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RECORDER_REQUEST = 1
|
||||
const val FRESH_MS = 120_000L
|
||||
|
|
|
|||
|
|
@ -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