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:
parent
c7acec3a05
commit
405577050d
2 changed files with 2672 additions and 45 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue