feat(train): the workouts listed, and each one's page with its map (ADR 0030, D22-D24)

Home lists the person's workouts newest first under their months, each
with its activity's icon, its name (else the time of day's and the
activity's), when it started, its distance, moving time and pace or
speed; those this device can't open come last, locked. A workout's page
shows its track on the instance's map source (flutter_map with
vector_map_tiles, pinned at 9.0.0-beta.13), in pieces between its
pauses, start and end marked; its totals with the elevation gained;
its splits per kilometre on foot, per 5 km cycling; pace or speed and
elevation over the distance, drawn by the app's own painter; and what
the map's source sees. Its name and notes are edited there; deleting
asks first and can be undone for a moment from home (halkyon_app's
DelayedDeletions, TrainPerson.deletions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 14:29:39 +01:00
commit 405577050d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,13 +1,18 @@
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/app/train_app.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -42,6 +47,7 @@ void main() {
FakeSimpleLoginGateway? simpleLogin,
RecordingDevice? recording,
WorkoutRepository? workouts,
bool mapReachable = true,
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -111,6 +117,11 @@ void main() {
),
),
recording: recording,
maps: MapSourceRepository(
_FakeMapSourceGateway(mapReachable ? openFreeMap : null),
),
// No tiles: widget tests reach no network.
mapTiles: (source) => const SizedBox.shrink(),
);
},
),
@ -159,13 +170,14 @@ void main() {
expect(opened, isEmpty);
});
testWidgets("an account's home is empty until workouts come", (tester) async {
testWidgets("an account's home says where workouts come from while it has "
'none', (tester) async {
await start(tester, usedBefore: true);
expect(opened.single, FakeAuthGateway.person);
expect(find.text('Nothing here yet'), findsOneWidget);
expect(find.text('No workouts yet'), findsOneWidget);
expect(
find.text('Workouts come with a later version of Halkyon Train.'),
find.text('Workouts recorded on your phone show here.'),
findsOneWidget,
);
// A device that doesn't record (the desktop) offers no recording.
@ -478,4 +490,243 @@ void main() {
});
});
});
group('workouts (ADR 0030, D24)', () {
late MemoryWorkoutGateway rows;
late TrainKeys keys;
late WorkoutRepository workouts;
setUp(() {
rows = MemoryWorkoutGateway();
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
workouts = WorkoutRepository(rows, keys);
});
tearDown(() => keys.dispose());
/// A run of [km] at 5:00/km from [startedAt] (the device's time), a fix
/// every 10 s, 20 m higher in its second half.
Future<String> saveRun(
DateTime startedAt, {
double km = 2.5,
String? name,
}) {
final start = startedAt.toUtc();
final seconds = (km * 300).round();
final (content, series) = recordedWorkout(
Recording(
activity: Activity.running,
startedAt: start,
fixes: [
for (var s = 0; s <= seconds; s += 10)
Fix(
time: start.add(Duration(seconds: s)),
latE7: (s * 10 / 3 * 89.932).round(),
lonE7: 0,
accuracyCm: 500,
altitudeCm: s < seconds / 2 ? 10000 : 12000,
),
],
endedAt: start.add(Duration(seconds: seconds)),
),
);
return workouts.save(content.withText(name: name), series);
}
/// An evening's row on the machine, without GPS.
Future<String> saveRow() {
final start = DateTime(2026, 10, 28, 21).toUtc();
return workouts.save(
WorkoutContent(
activity: Activity.indoorRowing,
startedAt: start,
endedAt: start.add(const Duration(minutes: 31)),
movingTime: const Duration(minutes: 30),
),
const [],
);
}
Future<void> startWith(WidgetTester tester) =>
start(tester, usedBefore: true, workouts: workouts);
testWidgets('home lists them newest first under their months, named by '
'the time of day where the person named none', (tester) async {
final run = await saveRun(DateTime(2026, 10, 27, 7, 30));
await saveRun(DateTime(2026, 9, 3, 18), name: 'Lakeside');
await saveRow();
rows.workoutRows['locked'] = (
keyId: 'train1.1.elsewhere',
nonce: null,
payload: rows.workoutRows[run]!.payload,
);
await startWith(tester);
double top(String text) => tester.getTopLeft(find.text(text)).dy;
expect(find.text('No workouts yet'), findsNothing);
expect(top('October 2026'), lessThan(top('Evening row')));
expect(top('Evening row'), lessThan(top('Morning run')));
expect(top('Morning run'), lessThan(top('September 2026')));
expect(top('September 2026'), lessThan(top('Lakeside')));
expect(top('Lakeside'), lessThan(top('Locked workout')));
expect(find.text('2.50 km'), findsNWidgets(2));
expect(find.text('12:30'), findsNWidgets(2));
expect(find.text('5:00 /km'), findsNWidgets(2));
expect(find.text('30:00'), findsOneWidget);
expect(
find.text("This device doesn't hold the key it's sealed with."),
findsOneWidget,
);
});
testWidgets("a workout's page shows its map, totals, splits and charts, "
'and where the map comes from', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await startWith(tester);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
expect(find.byType(FlutterMap), findsOneWidget);
expect(find.text('2.50 km'), findsOneWidget);
expect(find.text('20 m'), findsOneWidget);
expect(find.text('whole time'), findsOneWidget);
expect(
find.text(
"Map by OpenFreeMap: it sees your device's address and the area "
'shown, not your track.',
),
findsOneWidget,
);
expect(find.text('What OpenFreeMap keeps'), findsOneWidget);
await tester.scrollUntilVisible(find.text('km 3'), 200);
expect(find.text('Splits'), findsOneWidget);
expect(find.text('km 1'), findsOneWidget);
expect(find.text('km 2'), findsOneWidget);
await tester.scrollUntilVisible(find.text('Elevation'), 200);
expect(find.text('Pace'), findsOneWidget);
});
testWidgets('a workout without GPS has no map, splits nor charts', (
tester,
) async {
await saveRow();
await startWith(tester);
await tester.tap(find.text('Evening row'));
await tester.pumpAndSettle();
expect(find.text('30:00'), findsOneWidget);
expect(find.byType(FlutterMap), findsNothing);
expect(find.text('Splits'), findsNothing);
expect(find.text('Pace'), findsNothing);
});
testWidgets("where the instance can't be asked for its map source, the "
'page says so and shows the rest', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await start(
tester,
usedBefore: true,
workouts: workouts,
mapReachable: false,
);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
expect(find.byType(FlutterMap), findsNothing);
expect(find.text("The map can't be loaded now."), findsOneWidget);
expect(find.text('2.50 km'), findsOneWidget);
});
testWidgets('a workout renamed on its page is renamed in the list', (
tester,
) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await startWith(tester);
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Edit'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Name'),
'Before work',
);
await tester.enterText(
find.widgetWithText(TextField, 'Notes'),
'Legs heavy.',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Before work'), findsWidgets);
expect(find.text('Legs heavy.'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Before work'), findsOneWidget);
expect(find.text('Morning run'), findsNothing);
final saved = (await workouts.all()).single.content!;
expect(saved.name, 'Before work');
expect(saved.notes, 'Legs heavy.');
});
testWidgets('deleting asks first, leaves the list at once and can be '
'undone for a moment', (tester) async {
await saveRun(DateTime(2026, 10, 27, 7, 30));
await startWith(tester);
Future<void> delete() async {
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
expect(find.text('Delete this workout?'), findsOneWidget);
await tester.tap(find.text('Delete').last);
await tester.pumpAndSettle();
}
await tester.tap(find.text('Morning run'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('2.50 km'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await delete();
expect(find.text('Morning run'), findsNothing);
expect(find.text('Workout deleted'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('Morning run'), findsOneWidget);
await delete();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('Morning run'), findsNothing);
expect(await workouts.all(), isEmpty);
});
});
}
/// The instance's map source, or none where it can't be asked.
class _FakeMapSourceGateway(final MapSource? _source)
implements MapSourceGateway {
@override
Future<MapSource> source() async => _source ?? (throw StateError('offline'));
}
final openFreeMap = MapSource(
name: 'OpenFreeMap',
styleUrl: 'https://tiles.openfreemap.org/styles/liberty',
attribution: 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap',
aboutUrl: 'https://openfreemap.org',
);