feat(train): Record has a card for each activity, its Start opening the sheet for it (ADR 0030, D60)
All checks were successful
CI / check (push) Successful in 41m1s
CI / deploy (push) Successful in 56s

The Record tab shows a card per activity it records, the interval plans
below. A card's Start opens the Record sheet for that activity, which
keeps every other choice; the sheet no longer chooses the activity, and
so offers the plans and routes for it or for any. While a recording is
under way or waits to be saved, the cards wait, the bar leading back.

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-31 18:00:45 +01:00
commit f04388225b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -35,6 +35,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.da
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/recording/widget/record_sheet.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_bar.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
@ -45,6 +46,7 @@ import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repositor
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/week_card.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';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@ -247,9 +249,18 @@ void main() {
}
/// The Record tab's button, saying [label].
Finder recordButton([String label = 'Record']) => find.descendant(
of: find.byType(FloatingActionButton),
matching: find.text(label),
Finder startFor([String activity = 'Running']) => find.descendant(
of: find.ancestor(
of: find.text(activity),
matching: find.byType(ActivityCard),
),
matching: find.text('Start'),
);
/// The Record sheet's own Start, over the cards' (D60).
final sheetStart = find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('Start'),
);
/// Opens Settings from Home's cog.
@ -338,7 +349,7 @@ void main() {
expect(tabs(tester), ['Home', 'Maps', 'Record', 'Groups', 'Profile']);
await goTab(tester, 'Record');
expect(recordButton(), findsOneWidget);
expect(startFor(), findsOneWidget);
});
testWidgets("a device that doesn't record has no Record tab, and its "
@ -1285,6 +1296,12 @@ void main() {
}
}
/// Leaves the Record sheet without starting.
Future<void> closeSheet(WidgetTester tester) async {
Navigator.of(tester.element(find.byType(SheetScaffold).last)).pop();
await tester.pumpAndSettle();
}
/// Lets [seconds] pass, the screen following.
Future<void> pass(WidgetTester tester, [int seconds = 1]) async {
for (var i = 0; i < seconds; i++) {
@ -1292,18 +1309,58 @@ void main() {
}
}
testWidgets('the Record tab has a card for each activity, its Start '
"opening the sheet for it, the plans below; while a recording's under "
'way, they wait (D60)', (tester) async {
await withClock(Clock(() => at), () async {
await startRecording(tester);
final cards = find.byType(ActivityCard);
expect(
[
for (final card in tester.widgetList<ActivityCard>(cards))
card.activity,
],
recordedActivities,
);
expect(
tester.getTopLeft(find.text('Interval plans')).dy,
greaterThan(tester.getBottomLeft(cards.last).dy),
);
await tester.tap(startFor('Walking'));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('Walking'),
),
findsOneWidget,
);
await tester.tap(sheetStart);
await pass(tester, 2);
expect(platform.started.single.activity, Activity.walking);
await tester.pageBack();
await tester.pumpAndSettle();
for (final card in tester.widgetList<ActivityCard>(cards)) {
expect(card.startable, isFalse, reason: '${card.activity}');
}
expect(find.byType(RecordingBar), findsOneWidget);
});
});
testWidgets('a run plays the metronome chosen, Music not offered '
'without kept tracks, a ride neither (D48)', (tester) async {
await withClock(Clock(() => at), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor('Cycling'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cycling'));
await tester.pump();
expect(find.text('Metronome'), findsNothing);
await tester.tap(find.text('Running'));
await tester.pump();
await closeSheet(tester);
await tester.tap(startFor());
await tester.pumpAndSettle();
final music = find.widgetWithText(
SegmentedButton<MusicChoice>,
'Music',
@ -1322,7 +1379,7 @@ void main() {
);
await tester.tap(find.text('Metronome'));
await tester.pump();
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await tester.runAsync(pumpEventQueue);
await pass(tester, 2);
@ -1340,11 +1397,13 @@ void main() {
];
await withClock(Clock(() => at), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
expect(find.text('Power meter'), findsNothing);
await tester.tap(find.text('Cycling'));
await tester.pump();
await closeSheet(tester);
await tester.tap(startFor('Cycling'));
await tester.pumpAndSettle();
expect(find.text('Speed and cadence sensor'), findsOneWidget);
await tester.ensureVisible(find.text('Power meter'));
await tester.pump();
@ -1353,7 +1412,7 @@ void main() {
await tester.tap(find.text('Assioma'));
await pass(tester, 2);
expect(find.text('Assioma'), findsOneWidget);
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await tester.runAsync(pumpEventQueue);
await pass(tester, 2);
@ -1367,18 +1426,17 @@ void main() {
'remembered, a row offered none (D49)', (tester) async {
await withClock(Clock(() => at), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor('Cycling'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cycling'));
await tester.pump();
expect(
find.text('Every 5 km: time, speed and heart rate'),
findsOneWidget,
);
await tester.tap(find.text('Announcements'));
await tester.pump();
await tester.tap(find.text('Running'));
await tester.pump();
await closeSheet(tester);
await tester.tap(startFor());
await tester.pumpAndSettle();
expect(
find.text('Every kilometre: time, pace and heart rate'),
findsOneWidget,
@ -1391,12 +1449,25 @@ void main() {
.value,
isFalse,
);
await tester.tap(find.text('Rowing indoors'));
await tester.pump();
await closeSheet(tester);
await tester.ensureVisible(startFor('Rowing indoors'));
await tester.tap(startFor('Rowing indoors'));
await tester.pumpAndSettle();
expect(find.text('Announcements'), findsNothing);
await tester.tap(find.text('Cycling'));
await tester.pump();
await tester.tap(find.text('Start'));
// Back on the ride's sheet, its switch as it was left.
await closeSheet(tester);
await tester.tap(startFor('Cycling'));
await tester.pumpAndSettle();
expect(
tester
.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Announcements'),
)
.value,
isTrue,
);
await tester.tap(sheetStart);
await tester.runAsync(pumpEventQueue);
await pass(tester, 2);
@ -1414,12 +1485,16 @@ void main() {
await withClock(Clock(() => now), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor('Cycling'));
await tester.pumpAndSettle();
expect(find.text('Record a workout'), findsOneWidget);
await tester.tap(find.text('Cycling'));
await tester.pump();
await tester.tap(find.text('Start'));
expect(
find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('Cycling'),
),
findsOneWidget,
);
await tester.tap(sheetStart);
await pass(tester, 2);
expect(platform.started.single.activity, Activity.cycling);
@ -1462,7 +1537,7 @@ void main() {
findsNothing,
);
expect(ride, findsWidgets);
expect(recordButton(), findsNothing);
expect(startFor(), findsNothing);
final saved = (await workouts.all()).single.content!;
expect(saved.activity, Activity.cycling);
expect(saved.distanceMetres, 100);
@ -1475,9 +1550,9 @@ void main() {
) async {
await withClock(Clock.fixed(at), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await pass(tester, 2);
await tester.tap(find.text('Pause'));
@ -1497,9 +1572,9 @@ void main() {
var now = at;
await withClock(Clock(() => now), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await pass(tester, 2);
platform
..write('p @ 0 0 500 -')
@ -1544,9 +1619,9 @@ void main() {
platform.allows = {RecorderPermission.notifications};
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await tester.pumpAndSettle();
expect(
@ -1614,7 +1689,7 @@ void main() {
platform.runOn(file().path);
await startRecording(tester);
await tester.tap(recordButton('Back to the recording'));
await tester.tap(find.byType(RecordingBar));
await pass(tester, 2);
expect(find.text('Walking'), findsOneWidget);
@ -1623,8 +1698,8 @@ void main() {
});
});
testWidgets('a plan chosen on the Record sheet chooses its activity, goes '
'to the recorder spoken, its step shows with Lap (D42), and the '
testWidgets("a plan offered on its activity's Record sheet alone (D60), "
'chosen there, goes to the recorder spoken, its step shows with Lap (D42), and the '
'workout keeps its steps (D43)', (
tester,
) async {
@ -1654,10 +1729,22 @@ void main() {
await withClock(Clock(() => at), () async {
await startRecording(tester, plans: plans);
await tester.runAsync(pumpEventQueue);
await tester.tap(recordButton());
// A ride's plan isn't offered for a run.
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(find.text('Plan'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(
find.widgetWithText(RadioListTile<PlanContent?>, 'Sprints'),
findsNothing,
);
await closeSheet(tester);
await closeSheet(tester);
await tester.tap(startFor('Cycling'));
await tester.pumpAndSettle();
await tester.tap(find.text('Running'));
await tester.pump();
await tester.tap(find.text('Plan'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
@ -1671,7 +1758,7 @@ void main() {
),
findsOneWidget,
);
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await tester.runAsync(pumpEventQueue);
await pass(tester, 2);
@ -1729,7 +1816,7 @@ void main() {
});
});
testWidgets('a route chosen on the Record sheet chooses its activity and '
testWidgets("a route offered on its activity's Record sheet alone (D60) "
'goes to the recorder with its turns spoken; the next turn shows, '
'then the way back off it, and the map is swiped to (D45)', (
tester,
@ -1758,10 +1845,21 @@ void main() {
await withClock(Clock(() => at), () async {
await startRecording(tester, routes: routes);
await tester.runAsync(pumpEventQueue);
await tester.tap(recordButton());
// A ride's route isn't offered for a run.
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(find.text('Running'));
await tester.ensureVisible(find.text('Route'));
await tester.pump();
await tester.tap(find.text('Route'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(find.text('River'), findsNothing);
await closeSheet(tester);
await closeSheet(tester);
await tester.tap(startFor('Cycling'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Route'));
await tester.pump();
await tester.tap(find.text('Route'));
@ -1770,7 +1868,7 @@ void main() {
await tester.tap(find.text('River'));
await tester.pumpAndSettle();
expect(find.text('River'), findsOneWidget);
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await tester.runAsync(pumpEventQueue);
await pass(tester, 2);
@ -1831,7 +1929,7 @@ void main() {
];
await withClock(Clock.fixed(at), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
expect(find.text('No strap'), findsOneWidget);
@ -1849,7 +1947,7 @@ void main() {
expect(find.text('Heart-rate strap'), findsNothing);
expect(find.text('Polar H10 1234'), findsOneWidget);
expect(straps().read()?.address, polar.address);
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await pass(tester, 2);
expect(platform.started.single.strap, polar.address);
});
@ -1861,7 +1959,7 @@ void main() {
straps().save(KnownDevice(address: polar.address, name: polar.name));
await withClock(Clock.fixed(at), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(
@ -1871,7 +1969,7 @@ void main() {
),
);
await tester.pump();
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await pass(tester, 2);
expect(platform.started.single.strap, isNull);
@ -1883,7 +1981,7 @@ void main() {
straps().save(KnownDevice(address: polar.address, name: polar.name));
await withClock(Clock.fixed(at), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(find.text('Heart rate'));
await pass(tester, 2);
@ -1898,7 +1996,7 @@ void main() {
testWidgets('refused nearby devices, the sheet says why', (tester) async {
platform.allows = {RecorderPermission.location};
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(find.text('Heart rate'));
await tester.pumpAndSettle();
@ -1918,9 +2016,9 @@ void main() {
var now = at;
await withClock(Clock(() => now), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await pass(tester, 2);
platform.write('strap @ connecting');
@ -1961,11 +2059,11 @@ void main() {
);
await withClock(Clock.fixed(at), () async {
await startRecording(tester, loop: loop);
await tester.tap(recordButton());
await tester.tap(startFor());
await tester.pumpAndSettle();
expect(find.text('From the Polar Loop'), findsOneWidget);
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await pass(tester, 2);
expect(platform.started.single.loop, 'A1');
expect(platform.started.single.strap, isNull);
@ -1990,11 +2088,9 @@ void main() {
platform.nearbyRowers = [rower];
await withClock(Clock.fixed(at), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.ensureVisible(startFor('Rowing indoors'));
await tester.tap(startFor('Rowing indoors'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Rowing indoors'));
await tester.tap(find.text('Rowing indoors'));
await tester.pump();
await tester.ensureVisible(find.text('Rowing machine'));
await tester.pump();
@ -2002,7 +2098,10 @@ void main() {
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Start'),
find.ancestor(
of: sheetStart,
matching: find.byType(FilledButton),
),
)
.onPressed,
isNull,
@ -2013,7 +2112,7 @@ void main() {
await pass(tester, 2);
expect(find.text('MRK-950R-8021'), findsOneWidget);
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await pass(tester, 2);
expect(platform.started.single.rower, rower.address);
expect(platform.started.single.autoPause, AutoPauseBy.strokes);
@ -2029,12 +2128,10 @@ void main() {
var now = at;
await withClock(Clock(() => now), () async {
await startRecording(tester);
await tester.tap(recordButton());
await tester.ensureVisible(startFor('Rowing indoors'));
await tester.tap(startFor('Rowing indoors'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Rowing indoors'));
await tester.tap(find.text('Rowing indoors'));
await tester.pump();
await tester.tap(find.text('Start'));
await tester.tap(sheetStart);
await pass(tester, 2);
platform.write('rower @ connecting');