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:
Tizian 「ティツィアーン」 2026-11-03 22:02:06 +01:00
commit 3e90753b08
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 1518 additions and 358 deletions

View file

@ -56,9 +56,11 @@ import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.d
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/week_card.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_screen.dart';
import 'package:halkyon_train_flutter/src/tabs/maps_tab.dart';
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -289,6 +291,21 @@ void main() {
await tester.pumpAndSettle();
}
/// The route planner's help, behind its question mark rather than over
/// the map (D63): opened, read and closed.
Future<void> expectRouteHelp(WidgetTester tester) async {
const help =
'Tap the map to add a waypoint, tap one to take it off. Hold '
'one to drag it, or hold the line to pull out another.';
expect(find.text(help), findsNothing);
await tester.tap(find.byTooltip("What's this?"));
await tester.pumpAndSettle();
expect(find.text('Planning a route'), findsOneWidget);
expect(find.text(help), findsOneWidget);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
/// Goes to the tab [label] of the navigation bar (ADR 0030, D60).
Future<void> goTab(WidgetTester tester, String label) async {
await tester.tap(
@ -2122,20 +2139,16 @@ void main() {
await pass(tester);
expect(find.text('Save this workout?'), findsOneWidget);
// Saved, its page opens, in Home's tab (D60).
// Saved, its page opens, over the tabs (D63).
await tester.tap(find.text('Save'));
await pass(tester, 2);
final ride = find.textContaining(RegExp('ride', caseSensitive: false));
expect(
find.descendant(of: find.byType(AppBar), matching: ride),
findsOneWidget,
);
final page = find.byType(WorkoutScreen);
expect(find.descendant(of: page, matching: ride), findsWidgets);
expect(find.byType(FloatingNavigationBar), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
expect(
find.descendant(of: find.byType(AppBar), matching: ride),
findsNothing,
);
expect(page, findsNothing);
expect(ride, findsWidgets);
expect(startFor(), findsNothing);
final saved = (await workouts.all()).single.content!;
@ -2404,7 +2417,9 @@ void main() {
StepKind.recovery,
]);
// The workout, in Home.
// The workout, over the tabs (D63), and again from Home.
await tester.pageBack();
await tester.pumpAndSettle();
await goTab(tester, 'Home');
await tester.tap(find.text('0:50'));
await tester.runAsync(pumpEventQueue);
@ -3049,13 +3064,16 @@ void main() {
folder.deleteSync(recursive: true);
});
/// Maps, on a phone at [at] (null: a device that can't tell).
/// Maps, on a phone at [at] (null: a device that can't tell), last known
/// at [last], with [workouts].
Future<void> openRoutes(
WidgetTester tester, {
({double lat, double lon})? at,
({double lat, double lon})? last,
OfflineMaps? offlineMaps,
RouteFiles? routeFiles,
PlaceSearchGateway? placeSearch,
WorkoutRepository? workouts,
}) async {
await start(
tester,
@ -3065,10 +3083,13 @@ void main() {
placeSearch: placeSearch,
offlineMaps: offlineMaps,
routeFiles: routeFiles,
recording: at == null
workouts: workouts,
recording: at == null && last == null
? null
: (
platform: FakeRecorderPlatform()..at = at,
platform: FakeRecorderPlatform()
..at = at
..lastAt = last,
file: File('${folder.path}/recording.txt'),
devices: {
for (final kind in DeviceKind.values)
@ -3109,13 +3130,7 @@ void main() {
);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(
find.text(
'Tap the map to add a waypoint, tap one to take it off. Hold '
'one to drag it, or hold the line to pull out another.',
),
findsOneWidget,
);
await expectRouteHelp(tester);
await tester.enterText(find.byType(TextField).first, 'Tempelhof');
// Past the pause after typing.
@ -3154,13 +3169,7 @@ void main() {
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
expect(
find.text(
'Tap the map to add a waypoint, tap one to take it off. Hold '
'one to drag it, or hold the line to pull out another.',
),
findsOneWidget,
);
await expectRouteHelp(tester);
await tapMap(tester, const Offset(60, 40));
expect(
@ -3203,6 +3212,82 @@ void main() {
expect(route.content?.planned, isNotNull);
});
group("a new route's map opens near the person (D63)", () {
/// The planner's camera.
MapCamera camera(WidgetTester tester) => MapCamera.of(
tester.element(
find.descendant(
of: find.byType(FlutterMap),
matching: find.byType(MarkerLayer),
),
),
);
testWidgets('where the phone was last known, closer, before any fix', (
tester,
) async {
await openRoutes(tester, last: (lat: 52.52, lon: 13.405));
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(camera(tester).zoom, 14);
expect(camera(tester).center.latitude, closeTo(52.52, 1e-6));
expect(find.text('1'), findsNothing, reason: 'no waypoint set');
});
testWidgets('else where the newest workout with a track started', (
tester,
) async {
final workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
final start = DateTime.utc(2026, 10, 20, 7);
await workouts.save(
WorkoutContent(
activity: Activity.running,
startedAt: start,
endedAt: start.add(const Duration(minutes: 20)),
),
chunksOf(
SeriesKind.position,
start: start,
times: const [0],
columns: {
'lat': [475500000],
'lon': [96800000],
},
),
);
await openRoutes(tester, workouts: workouts);
await tester.tap(find.text('New route'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(camera(tester).zoom, 14);
expect(camera(tester).center.latitude, closeTo(47.55, 1e-6));
expect(camera(tester).center.longitude, closeTo(9.68, 1e-6));
});
testWidgets('else the region, as before', (tester) async {
await openRoutes(tester);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(camera(tester).zoom, lessThan(10));
});
});
testWidgets("from expanded windows on, the planner's details beside its "
'map (D63)', (tester) async {
await openRoutes(tester);
tester.view.physicalSize = const Size(1280, 800);
await tester.tap(find.text('New route'));
await tester.pumpAndSettle();
expect(
tester.getTopRight(find.byType(FlutterMap)).dx,
lessThanOrEqualTo(tester.getTopLeft(find.text('Name')).dx),
);
});
testWidgets('the activity picks the profile, which plans the way again', (
tester,
) async {
@ -3758,8 +3843,9 @@ void main() {
);
});
testWidgets('home and a workout’s page run on beneath the floating bar, '
'their lists ending clear of it', (tester) async {
testWidgets('home runs on beneath the floating bar, its list ending '
"clear of it; a workout's page covers the bar, its sheet ending clear "
"of the screen's foot (D63)", (tester) async {
for (var day = 1; day <= 12; day++) {
await saveRun(DateTime(2026, 10, day, 7, 30));
}
@ -3790,7 +3876,21 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Morning run').first);
await tester.pumpAndSettle();
await endsClearOfTheBar(find.byType(Scrollable).last);
expect(bar, findsNothing);
final sheet = find.descendant(
of: find.byType(DraggableScrollableSheet),
matching: find.byType(Scrollable),
);
for (var i = 0; i < 3; i++) {
await tester.drag(sheet, const Offset(0, -5000));
await tester.pumpAndSettle();
}
final items = find.descendant(of: sheet, matching: find.byType(Text));
expect(
tester.getBottomLeft(items.last).dy,
lessThanOrEqualTo(screen - 48),
);
});
testWidgets('above them, this week: its time, distance and workouts, and '
@ -3891,14 +3991,18 @@ void main() {
expect(find.text('2.50 km'), findsOneWidget);
expect(find.text('20 m'), findsOneWidget);
expect(find.text('whole time'), findsOneWidget);
expect(
find.text(
// What the map's source sees, one tap away (D63).
const sees =
"Map by OpenFreeMap: it sees your device's address and the area "
'shown, not your track.',
),
findsOneWidget,
);
'shown, not your track.';
expect(find.text(sees), findsNothing);
await tester.tap(find.byTooltip("What's this?"));
await tester.pumpAndSettle();
expect(find.text('About the map'), findsOneWidget);
expect(find.text(sees), findsOneWidget);
expect(find.text('What OpenFreeMap keeps'), findsOneWidget);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('km 3'), 200);
expect(find.text('Splits'), findsOneWidget);
expect(find.text('km 1'), findsOneWidget);
@ -3925,6 +4029,8 @@ void main() {
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip("What's this?"));
await tester.pumpAndSettle();
expect(
find.text(
@ -3936,6 +4042,115 @@ void main() {
expect(find.textContaining('sees your device'), findsNothing);
});
group("a workout's page as Strava's (D63)", () {
MapCamera camera(WidgetTester tester) =>
MapCamera.of(tester.element(find.byType(PolylineLayer)));
Future<void> openRun(WidgetTester tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await startWith(tester);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
}
testWidgets('its map to the foot of the screen, under a sheet with its '
'name; pulled down, the map fits the track anew, closer', (
tester,
) async {
await openRun(tester);
expect(
tester.getBottomLeft(find.byType(FlutterMap)).dy,
tester.view.physicalSize.height,
);
final sheet = find.byType(DraggableScrollableSheet);
expect(
find.descendant(of: sheet, matching: find.text('Morning run')),
findsOneWidget,
);
final before = camera(tester);
await tester.drag(find.text('2.50 km'), const Offset(0, 400));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(camera(tester).zoom, greaterThan(before.zoom));
expect(
camera(tester).center.latitude,
greaterThan(before.center.latitude),
reason:
'the track lower on the larger map: the map looks south of it',
);
});
testWidgets("pulled up, the round back button turns into a bar's "
'chevron that lowers the sheet again', (tester) async {
await openRun(tester);
expect(find.byTooltip('Back'), findsOneWidget);
expect(find.byTooltip('Show the map'), findsNothing);
await tester.drag(find.text('2.50 km'), const Offset(0, -2000));
await tester.pumpAndSettle();
expect(find.byTooltip('Show the map'), findsOneWidget);
expect(find.byTooltip('Back'), findsNothing);
await tester.tap(find.byTooltip('Show the map'));
await tester.pumpAndSettle();
expect(find.byTooltip('Back'), findsOneWidget);
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
expect(find.byType(WorkoutScreen), findsNothing);
});
testWidgets('from expanded windows on, the map beside the rest', (
tester,
) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await startWith(tester);
tester.view.physicalSize = const Size(1280, 800);
await tester.pumpAndSettle();
await tester.tap(find.text('Morning run').last);
await tester.pumpAndSettle();
expect(find.byType(DraggableScrollableSheet), findsNothing);
expect(
tester.getTopRight(find.byType(FlutterMap)).dx,
lessThanOrEqualTo(tester.getTopLeft(find.text('2.50 km')).dx),
);
});
testWidgets("the map's credit opens on the theme's surface, in its "
'colours', (tester) async {
await openRun(tester);
final credit = tester.widget<RichAttributionWidget>(
find.byType(RichAttributionWidget),
);
final scheme = Theme.of(
tester.element(find.byType(RichAttributionWidget)),
).colorScheme;
expect(credit.popupBackgroundColor, scheme.surfaceContainerHigh);
await tester.tap(find.byTooltip('Map credits'));
await tester.pumpAndSettle();
final text = find.textContaining('OpenFreeMap © OpenMapTiles');
expect(text, findsOneWidget);
expect(
tester
.widget<Text>(
find
.descendant(
of: find.byType(TextSourceAttribution),
matching: find.byType(Text),
)
.first,
)
.style
?.color,
scheme.onSurface,
);
});
});
testWidgets("a workout with a strap shows the heart's average and highest "
'rate, by split too, and its chart', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30), strap: true);

View file

@ -59,6 +59,87 @@ void main() {
expect(tileHeadersOf(provider), isNull);
},
);
group("a provider's places in the app's language (D63)", () {
// OpenFreeMap's Liberty, as it labels places: English, else the name
// on the spot.
final liberty = {
'version': 8,
'layers': [
{
'id': 'poi',
'type': 'symbol',
'paint': {'text-color': '#333333'},
'layout': {
'text-field': [
'case',
['has', 'name:nonlatin'],
[
'concat',
['get', 'name:latin'],
'\n',
['get', 'name:nonlatin'],
],
[
'coalesce',
['get', 'name_en'],
['get', 'name'],
],
],
},
},
{
'id': 'old',
'type': 'symbol',
'paint': {'text-color': '#333333'},
'layout': {'text-field': '{name_en}'},
},
{
'id': 'road-ref',
'type': 'symbol',
'paint': {'text-color': '#333333'},
'layout': {
'text-field': [
'to-string',
['get', 'ref'],
],
},
},
{'id': 'water', 'type': 'fill'},
],
};
test('in German, the German name before the name on the spot', () {
final german = labelledIn(liberty, 'de');
final layers = german['layers']! as List<Object?>;
expect(
jsonEncode((layers[0]! as Map)['layout']),
contains('["coalesce",["get","name:de"],["get","name"]]'),
);
expect(
((layers[1]! as Map)['layout'] as Map)['text-field'],
'{name:de}',
);
expect(
jsonEncode(layers[2]),
jsonEncode((liberty['layers']! as List)[2]),
);
expect(
jsonEncode(layers[3]),
jsonEncode((liberty['layers']! as List)[3]),
);
expect(jsonEncode(liberty), contains('name_en'), reason: 'unchanged');
});
test('in English, as the style has it; read without a warning', () {
expect(jsonEncode(labelledIn(liberty, 'en')), jsonEncode(liberty));
final warnings = <String>[];
ThemeReader(
logger: _Warnings(warnings),
).read(labelledIn(liberty, 'de'));
expect(warnings, isEmpty);
});
});
}
class _Warnings(final List<String> _warnings) implements Logger {

View file

@ -0,0 +1,84 @@
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/track_camera.dart';
import 'package:latlong2/latlong.dart';
/// Where a workout's track is fitted on its page (ADR 0030, D63): into the
/// map left free above the sheet and below the buttons, and how the camera
/// moves there.
void main() {
group('the room the track is fitted into', () {
test('clear of the buttons on top and of the sheet below, a margin '
'around', () {
final padding = trackFitPadding(
view: const Size(400, 800),
top: 24,
covered: 440,
);
expect(padding.top, 24 + 56 + 32);
expect(padding.bottom, 440 + 32);
expect(padding.left, 32);
expect(padding.right, 32);
});
test(
'the sheet pulled down: the map is larger, the track fitted lower',
() {
final up = trackFitPadding(
view: const Size(400, 800),
top: 24,
covered: 440,
);
final down = trackFitPadding(
view: const Size(400, 800),
top: 24,
covered: 120,
);
expect(down.bottom, lessThan(up.bottom));
expect(down.top, up.top);
},
);
test(
'a sheet covering nearly all of it still leaves the track a strip',
() {
final padding = trackFitPadding(
view: const Size(400, 800),
top: 24,
covered: 780,
);
expect(
800 - padding.top - padding.bottom,
greaterThanOrEqualTo(minimumTrackRoom),
);
expect(padding.bottom, greaterThanOrEqualTo(0));
},
);
});
group('the camera on its way', () {
const from = (center: LatLng(47.5, 9.6), zoom: 12.0);
const to = (center: LatLng(47.6, 9.8), zoom: 14.0);
test('starts where it was and ends where it goes', () {
expect(cameraBetween(from, to, 0), from);
expect(cameraBetween(from, to, 1), to);
});
test('halfway, halfway in place and zoom', () {
final half = cameraBetween(from, to, 0.5);
expect(half.center.latitude, closeTo(47.55, 1e-9));
expect(half.center.longitude, closeTo(9.7, 1e-9));
expect(half.zoom, 13);
});
});
group('the sheet', () {
test('folds the round button into the bar over its last stretch', () {
expect(barShown(0.55), 0);
expect(barShown(trackBarFrom), 0);
expect(barShown(1), 1);
expect(barShown((trackBarFrom + 1) / 2), closeTo(0.5, 1e-9));
});
});
}

View file

@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
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/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_workout_gateway.dart';
/// Where the newest workout with a track started (ADR 0030, D63): where a
/// new route's map opens when the phone can't say where it is.
void main() {
late TrainKeys keys;
late WorkoutRepository workouts;
setUp(() {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
});
tearDown(() => keys.dispose());
Future<void> save(DateTime start, {LatLng? at}) => workouts.save(
WorkoutContent(
activity: at == null ? Activity.indoorRowing : Activity.running,
startedAt: start,
endedAt: start.add(const Duration(minutes: 20)),
),
[
if (at != null)
...chunksOf(
SeriesKind.position,
start: start,
times: const [0, 10000],
columns: {
'lat': [null, (at.latitude * 1e7).round()],
'lon': [null, (at.longitude * 1e7).round()],
},
),
],
);
test('the newest with a track, at its first fix; a newer row without one '
'passed over', () async {
await save(DateTime.utc(2026, 10, 2), at: const LatLng(47.55, 9.68));
await save(DateTime.utc(2026, 10, 20), at: const LatLng(52.52, 13.4));
await save(DateTime.utc(2026, 10, 30));
final start = await lastWorkoutStart(workouts);
expect(start!.latitude, closeTo(52.52, 1e-6));
expect(start.longitude, closeTo(13.4, 1e-6));
});
test('none without a workout with a track', () async {
await save(DateTime.utc(2026, 10, 30));
expect(await lastWorkoutStart(workouts), isNull);
});
}

View file

@ -159,6 +159,13 @@ class FakeRecorderPlatform({
Future<({double lat, double lon})?> position() async =>
allows.contains(RecorderPermission.location) ? at : null;
/// Where the phone was last known to be; null: nowhere.
({double lat, double lon})? lastAt;
@override
Future<({double lat, double lon})?> lastPosition() async =>
allows.contains(RecorderPermission.location) ? lastAt : null;
final _openings = StreamController<void>.broadcast();
@override