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
3 changed files with 2672 additions and 45 deletions

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',
);

View file

@ -0,0 +1,170 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a workout's page shows of its series (ADR 0030, D24): the track in
/// pieces between its pauses, its splits, the elevation gained and its
/// charts, read from the series as they were saved.
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
DateTime at(int seconds) => start.add(Duration(seconds: seconds));
/// A fix [metres] north of where the equator crosses Greenwich.
Fix north(int seconds, num metres, {int? altitudeCm, int accuracyCm = 500}) =>
Fix(
time: at(seconds),
latE7: (metres * 89.932).round(),
lonE7: 0,
accuracyCm: accuracyCm,
altitudeCm: altitudeCm,
);
/// [recording] saved and read back, as the page reads it.
WorkoutAnalysis analysed(Recording recording) {
final (content, series) = recordedWorkout(recording);
return analyse(content, series);
}
Recording run(
List<Fix> fixes, {
List<PauseMark> pauses = const [],
Activity activity = Activity.running,
}) => Recording(
activity: activity,
startedAt: start,
fixes: fixes,
pauses: pauses,
endedAt: fixes.isEmpty ? at(600) : fixes.last.time,
);
test('the track is in pieces between its pauses, imprecise fixes left '
'out', () {
final analysis = analysed(
run(
[
north(0, 0),
north(10, 50),
north(15, 300, accuracyCm: 4000),
north(20, 100),
north(40, 900),
north(60, 150),
north(70, 200),
],
pauses: [
PauseMark(time: at(30), auto: true),
PauseMark(time: at(50), paused: false, auto: true),
],
),
);
expect(
[
for (final piece in analysis.track) [for (final fix in piece) fix.time],
],
[
[at(0), at(10), at(20)],
[at(60), at(70)],
],
);
});
test('running splits by the kilometre, the last one as long as it went', () {
// 2.5 km at 5:00/km, a fix every 10 s.
final analysis = analysed(
run([for (var s = 0; s <= 750; s += 10) north(s, s * 10 / 3)]),
);
expect(analysis.splits.map((s) => s.metres), [1000, 1000, 500]);
for (final split in analysis.splits.take(2)) {
expect(split.movingTime.inSeconds, closeTo(300, 1));
}
expect(analysis.splits.last.movingTime.inSeconds, closeTo(150, 1));
});
test('a pause is left out of its split', () {
final analysis = analysed(
run(
[
for (var s = 0; s <= 150; s += 10) north(s, s * 10 / 3),
// Two minutes standing, then on.
for (var s = 270; s <= 420; s += 10) north(s, (s - 120) * 10 / 3),
],
pauses: [
PauseMark(time: at(151), auto: true),
PauseMark(time: at(269), paused: false, auto: true),
],
),
);
expect(analysis.splits.first.metres, 1000);
expect(analysis.splits.first.movingTime.inSeconds, closeTo(300, 2));
});
test('cycling splits by 5 km', () {
final analysis = analysed(
run(
[for (var s = 0; s <= 1200; s += 10) north(s, s * 25 / 3)],
activity: Activity.cycling,
),
);
expect(analysis.splits.map((s) => s.metres), [5000, 5000]);
expect(analysis.splits.first.movingTime.inSeconds, closeTo(600, 1));
});
test('the elevation gained counts climbs above 3 m, not the noise', () {
final metres = [100, 101, 99, 102, 100, 104, 108, 107, 112, 111, 109];
final analysis = analysed(
run([
for (final (i, m) in metres.indexed)
north(i * 10, i * 30, altitudeCm: m * 100),
]),
);
// From 100 m up to 112 m: the wobbles of 3 m or less, up or down, count
// for nothing.
expect(analysis.elevationGainMetres, 12);
});
test('without altitudes it has no elevation gained, nor its chart', () {
final analysis = analysed(run([north(0, 0), north(60, 200)]));
expect(analysis.elevationGainMetres, isNull);
expect(analysis.elevation, isEmpty);
});
test('the charts run over the distance: pace on foot, speed on a bike', () {
final foot = analysed(
run([for (var s = 0; s <= 600; s += 10) north(s, s * 10 / 3)]),
);
final bike = analysed(
run(
[for (var s = 0; s <= 600; s += 10) north(s, s * 25 / 3)],
activity: Activity.cycling,
),
);
expect(foot.chart, isNotEmpty);
expect(foot.chart.length, lessThanOrEqualTo(101));
expect(foot.chart.last.metres, closeTo(2000, 10));
for (final point in foot.chart) {
// Seconds a kilometre takes.
expect(point.value, closeTo(300, 5));
}
for (final point in bike.chart) {
// km/h.
expect(point.value, closeTo(30, 1));
}
});
test('a workout without fixes has no track, no splits and no charts', () {
final analysis = analysed(run(const [], activity: Activity.indoorRowing));
expect(analysis.track, isEmpty);
expect(analysis.splits, isEmpty);
expect(analysis.chart, isEmpty);
expect(analysis.elevationGainMetres, isNull);
});
}

View file

@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.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/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_workout_gateway.dart';
/// The person's workouts as home lists them (ADR 0030, D24): newest first,
/// the locked ones last, a deletion leaving the list at once and undone for
/// a moment.
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
late MemoryWorkoutGateway rows;
late TrainKeys keys;
late WorkoutRepository workouts;
late DelayedDeletions deletions;
late WorkoutsCubit cubit;
WorkoutContent on(int day, Activity activity) => WorkoutContent(
activity: activity,
startedAt: start.add(Duration(days: day)),
endedAt: start.add(Duration(days: day, hours: 1)),
);
setUp(() {
rows = MemoryWorkoutGateway();
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
workouts = WorkoutRepository(rows, keys);
deletions = DelayedDeletions(
workouts.delete,
undoWindow: const Duration(milliseconds: 50),
);
cubit = WorkoutsCubit(workouts, deletions);
});
tearDown(() async {
await cubit.close();
await deletions.dispose();
await workouts.dispose();
keys.dispose();
});
test("newest first, those this device can't open last", () async {
final older = await workouts.save(on(0, Activity.running), const []);
final newer = await workouts.save(on(2, Activity.cycling), const []);
rows.workoutRows['locked'] = (
keyId: 'train1.1.elsewhere',
nonce: null,
payload: rows.workoutRows[older]!.payload,
);
await cubit.open();
expect(cubit.state.loaded, isTrue);
expect(cubit.state.visible.map((w) => w.id), [newer, older, 'locked']);
});
test('a workout saved, here or on another device, comes in', () async {
await cubit.open();
expect(cubit.state.visible, isEmpty);
final id = await workouts.save(on(0, Activity.hiking), const []);
await pumpEventQueue();
expect(cubit.state.visible, [StoredWorkout(id, on(0, Activity.hiking))]);
});
test('a deletion leaves the list at once, and is undone within its '
'moment', () async {
final id = await workouts.save(on(0, Activity.running), const []);
await cubit.open();
cubit.delete(id);
await pumpEventQueue();
expect(cubit.state.visible, isEmpty);
cubit.undo(id);
await pumpEventQueue();
expect(cubit.state.visible.map((w) => w.id), [id]);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(await workouts.all(), hasLength(1));
});
test('once its moment is over, the workout is deleted', () async {
final id = await workouts.save(on(0, Activity.running), const []);
await cubit.open();
cubit.delete(id);
await Future<void>.delayed(const Duration(milliseconds: 150));
await pumpEventQueue();
expect(await workouts.all(), isEmpty);
expect(cubit.state.visible, isEmpty);
});
}