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
12 changed files with 333 additions and 205 deletions

File diff suppressed because one or more lines are too long

View file

@ -15,9 +15,7 @@
"aboutTitle": "Über Halkyon Train",
"homeRecordHint": "Zeichne eins im Tab Aufzeichnen auf.",
"recordAction": "Aufzeichnen",
"recordBack": "Zurück zur Aufzeichnung",
"recordSheetTitle": "Training aufzeichnen",
"activityRunning": "Laufen",
"activityWalking": "Gehen",
"activityHiking": "Wandern",
@ -573,5 +571,6 @@
"homeWeekTime": "Zeit",
"homeWeekDistance": "Distanz",
"homeWeekWorkouts": "Trainings",
"homeWeekDuration": "{hours, plural, =0{{minutes} Min.} other{{hours} Std. {minutes} Min.}}"
"homeWeekDuration": "{hours, plural, =0{{minutes} Min.} other{{hours} Std. {minutes} Min.}}",
"recordPlansHeading": "Intervallpläne"
}

View file

@ -17,9 +17,7 @@
"aboutTitle": "About Halkyon Train",
"homeRecordHint": "Record one from the Record tab.",
"recordAction": "Record",
"recordBack": "Back to the recording",
"recordSheetTitle": "Record a workout",
"activityRunning": "Running",
"activityWalking": "Walking",
"activityHiking": "Hiking",
@ -723,5 +721,6 @@
"homeWeekDistance": "Distance",
"homeWeekWorkouts": "Workouts",
"homeWeekDuration": "{hours, plural, =0{{minutes} min} other{{hours} h {minutes} min}}",
"@homeWeekDuration": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}}
"@homeWeekDuration": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}},
"recordPlansHeading": "Interval plans"
}

View file

@ -166,12 +166,6 @@ abstract class AppLocalizations {
/// **'Back to the recording'**
String get recordBack;
/// No description provided for @recordSheetTitle.
///
/// In en, this message translates to:
/// **'Record a workout'**
String get recordSheetTitle;
/// No description provided for @activityRunning.
///
/// In en, this message translates to:
@ -3465,6 +3459,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{hours, plural, =0{{minutes} min} other{{hours} h {minutes} min}}'**
String homeWeekDuration(int hours, int minutes);
/// No description provided for @recordPlansHeading.
///
/// In en, this message translates to:
/// **'Interval plans'**
String get recordPlansHeading;
}
class _AppLocalizationsDelegate

View file

@ -45,9 +45,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recordBack => 'Zurück zur Aufzeichnung';
@override
String get recordSheetTitle => 'Training aufzeichnen';
@override
String get activityRunning => 'Laufen';
@ -2083,4 +2080,7 @@ class AppLocalizationsDe extends AppLocalizations {
);
return '$_temp0';
}
@override
String get recordPlansHeading => 'Intervallpläne';
}

View file

@ -44,9 +44,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recordBack => 'Back to the recording';
@override
String get recordSheetTitle => 'Record a workout';
@override
String get activityRunning => 'Running';
@ -2071,6 +2068,9 @@ class AppLocalizationsEn extends AppLocalizations {
);
return '$_temp0';
}
@override
String get recordPlansHeading => 'Interval plans';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -11,8 +11,9 @@ import 'package:material_ui/material_ui.dart';
/// The person's interval plans as home lists them (ADR 0030, D42): by name,
/// each with its steps, time and distance; a new one made from nothing or
/// from a template.
class const PlansList({super.key}) extends StatelessWidget {
/// from a template; [header], where given, above them.
class const PlansList({final Widget? header, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -26,6 +27,7 @@ class const PlansList({super.key}) extends StatelessWidget {
return ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
?header,
for (final row in [
ListTile(
leading: const Icon(LucideIcons.plus),

View file

@ -40,33 +40,37 @@ bool autoPausesByDefault(Activity activity) =>
activity == Activity.cycling ||
activity == Activity.indoorRowing;
/// The sheet a recording starts from (D21): the activities, the one last
/// recorded chosen, auto-pause's switch, the heart-rate strap, chosen there
/// The sheet a recording of [activity] starts from (D21), its card's Start
/// on the Record tab having chosen it (D60): auto-pause's switch, the heart-rate strap, chosen there
/// and switched off for one workout (D27), the Loop named where it gives the
/// heart rate (D41), for a row the rower, without which it doesn't start
/// (D28), the interval plan to run, none by default (D42), and the route
/// to follow, none by default, for an activity with GPS (D45), and on foot
/// to follow, none by default, for an activity with GPS (D45), each of
/// those for [activity] or for any, and on foot
/// what it plays, as last chosen (D48), and whether it announces each
/// distance, as last switched for the activity (D49); true once
/// Start was pressed, the start asked of [bloc].
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
Future<bool> showRecordSheet(
BuildContext context,
RecordingBloc bloc,
Activity activity,
) async =>
await showAdaptiveSheet<bool>(
context,
builder: (context) => _RecordSheet(bloc: bloc),
builder: (context) => _RecordSheet(bloc: bloc, activity: activity),
) ??
false;
class const _RecordSheet({required final RecordingBloc bloc})
extends StatefulWidget {
class const _RecordSheet({
required final RecordingBloc bloc,
required final Activity activity,
}) extends StatefulWidget {
@override
State<_RecordSheet> createState() => _RecordSheetState();
}
class _RecordSheetState() extends State<_RecordSheet> {
late var _activity =
recordedActivities.contains(widget.bloc.state.lastActivity)
? widget.bloc.state.lastActivity
: Activity.running;
late final _activity = widget.activity;
late var _autoPause = autoPausesByDefault(_activity);
/// Whether this workout records with the strap, where there is one.
@ -91,34 +95,14 @@ class _RecordSheetState() extends State<_RecordSheet> {
context,
).recording!.announces.read();
/// [activity] chosen, with its auto-pause's default.
void _choose(Activity activity) {
_activity = activity;
_autoPause = autoPausesByDefault(activity);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.recordSheetTitle,
title: activityName(l10n, _activity),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RadioGroup<Activity>(
groupValue: _activity,
onChanged: (activity) => setState(() => _choose(activity!)),
child: Column(
children: [
for (final activity in recordedActivities)
RadioListTile<Activity>(
value: activity,
title: Text(activityName(l10n, activity)),
),
],
),
),
const Divider(),
SwitchListTile(
value: _autoPause,
onChanged: (value) => setState(() => _autoPause = value),
@ -258,16 +242,9 @@ class _RecordSheetState() extends State<_RecordSheet> {
subtitle: Text(_plan?.name ?? l10n.recordNoPlan),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () async {
final chosen = await _choosePlan(context, _plan);
final chosen = await _choosePlan(context, _activity, _plan);
if (chosen != null) {
setState(() {
_plan = chosen.plan;
// A plan for an activity chooses it.
if (chosen.plan?.activity case final activity?
when recordedActivities.contains(activity)) {
_choose(activity);
}
});
setState(() => _plan = chosen.plan);
}
},
),
@ -278,16 +255,9 @@ class _RecordSheetState() extends State<_RecordSheet> {
subtitle: Text(_route?.name ?? l10n.recordNoRoute),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () async {
final chosen = await _chooseRoute(context, _route);
final chosen = await _chooseRoute(context, _activity, _route);
if (chosen != null) {
setState(() {
_route = chosen.route;
// A route for an activity chooses it, as a plan does.
if (chosen.route?.activity case final activity?
when recordedActivities.contains(activity)) {
_choose(activity);
}
});
setState(() => _route = chosen.route);
}
},
),
@ -418,10 +388,11 @@ class _RecordSheetState() extends State<_RecordSheet> {
);
}
/// The plans to choose from, with None; null where the sheet was left
/// without a choice.
/// The plans for [activity] or for any to choose from, with None; null
/// where the sheet was left without a choice.
Future<({PlanContent? plan})?> _choosePlan(
BuildContext context,
Activity activity,
PlanContent? current,
) {
final l10n = AppLocalizations.of(context);
@ -442,7 +413,8 @@ Future<({PlanContent? plan})?> _choosePlan(
title: Text(l10n.recordNoPlan),
),
for (final stored in state.visible)
if (stored.content case final plan?)
if (stored.content case final plan?
when plan.activity == null || plan.activity == activity)
RadioListTile<PlanContent?>(
value: plan,
title: Text(plan.name),
@ -456,10 +428,11 @@ Future<({PlanContent? plan})?> _choosePlan(
);
}
/// The routes with a way planned to choose from, with None; null where the
/// sheet was left without a choice.
/// The routes with a way planned, for [activity] or for any, to choose
/// from, with None; null where the sheet was left without a choice.
Future<({RouteContent? route})?> _chooseRoute(
BuildContext context,
Activity activity,
RouteContent? current,
) {
final l10n = AppLocalizations.of(context);
@ -479,7 +452,9 @@ Future<({RouteContent? route})?> _chooseRoute(
title: Text(l10n.recordNoRoute),
),
for (final stored in state.visible)
if (stored.content case final route? when route.planned != null)
if (stored.content case final route?
when route.planned != null &&
(route.activity == null || route.activity == activity))
RadioListTile<RouteContent?>(
value: route,
title: Text(route.name),

View file

@ -1,17 +1,120 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plans_list.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/record_sheet.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_texts.dart';
import 'package:halkyon_train_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_train_flutter/src/tabs/train_shell.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Record (ADR 0030, D60), where the device records (D21): recording a
/// workout, or back to the one under way, and the interval plans to record
/// it by (D42).
/// Record (ADR 0030, D60), where the device records (D21): a card for each
/// activity, its Start opening the Record sheet for it, and the interval
/// plans to record by below them (D42). While a recording is under way or
/// waits to be saved, the cards wait too: the bar above the tabs leads back
/// to it.
class const RecordTab({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => TabScreen(
title: AppLocalizations.of(context).shellTabRecord,
body: const PlansList(),
floatingActionButton: const RecordButton(),
body: const PlansList(header: _Activities()),
);
}
class const _Activities() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final free = context.select<RecordingBloc, bool>(
(bloc) => switch (bloc.state.phase) {
RecordingPhase.idle || RecordingPhase.cutOff => true,
RecordingPhase.loading ||
RecordingPhase.recording ||
RecordingPhase.stopped => false,
},
);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final activity in recordedActivities)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ActivityCard(activity: activity, startable: free),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 4),
child: Text(
l10n.recordPlansHeading,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
],
),
),
),
);
}
}
/// [activity]'s card on the Record tab: its icon, its name and its Start,
/// which waits while [startable] is false.
class const ActivityCard({
required final Activity activity,
required final bool startable,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SectionCard(
padding: EdgeInsets.zero,
child: ListTile(
leading: Icon(activityIcon(activity)),
title: Text(activityName(l10n, activity)),
trailing: FilledButton.tonal(
onPressed: startable ? () => _record(context) : null,
child: Text(l10n.recordStart),
),
),
);
}
/// The Record sheet for [activity]; started, the recording's screen, or
/// why it couldn't start.
Future<void> _record(BuildContext context) async {
final bloc = context.read<RecordingBloc>();
final started = startedOrRefused(bloc);
if (!await showRecordSheet(context, bloc, activity) || !context.mounted) {
return;
}
final state = await started;
if (!context.mounted) {
return;
}
final l10n = AppLocalizations.of(context);
final refused = switch (state) {
RecordingState(locationRefused: true) => l10n.recordLocationRefused,
RecordingState(bluetoothRefused: true) => l10n.recordRowerRefused,
RecordingState(rowerMissing: true) => l10n.recordNoRower,
_ => null,
};
if (refused != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(refused)),
);
return;
}
await openRecordingScreen(context);
}
}

View file

@ -7,9 +7,7 @@ import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.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/recording/widget/recording_screen.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
@ -97,58 +95,6 @@ class const TrainShell({
}
}
/// Record, or back to the recording under way or stopped.
class const RecordButton({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocBuilder<RecordingBloc, RecordingState>(
buildWhen: (before, now) => before.phase != now.phase,
builder: (context, state) => switch (state.phase) {
RecordingPhase.loading => const SizedBox.shrink(),
RecordingPhase.recording ||
RecordingPhase.stopped => FloatingActionButton.extended(
onPressed: () => openRecordingScreen(context),
icon: const Icon(LucideIcons.activity),
label: Text(l10n.recordBack),
),
RecordingPhase.idle ||
RecordingPhase.cutOff => FloatingActionButton.extended(
onPressed: () => _record(context),
icon: const Icon(LucideIcons.play),
label: Text(l10n.recordAction),
),
},
);
}
Future<void> _record(BuildContext context) async {
final bloc = context.read<RecordingBloc>();
final started = startedOrRefused(bloc);
if (!await showRecordSheet(context, bloc) || !context.mounted) {
return;
}
final state = await started;
if (!context.mounted) {
return;
}
final l10n = AppLocalizations.of(context);
final refused = switch (state) {
RecordingState(locationRefused: true) => l10n.recordLocationRefused,
RecordingState(bluetoothRefused: true) => l10n.recordRowerRefused,
RecordingState(rowerMissing: true) => l10n.recordNoRower,
_ => null,
};
if (refused != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(refused)),
);
return;
}
await openRecordingScreen(context);
}
}
/// Offers [recording], found cut off, to be saved up to its last sample or
/// discarded, which asks first (D18).
Future<void> _offerCutOff(BuildContext context, Recording recording) async {

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

View file

@ -1509,6 +1509,13 @@ Strava. What was found, 27 Oct:
expanded windows on, as Material's list–detail layout has two panes there; without a
route to draw, the list alone, as there is nothing to centre on without asking the
phone where it is (the developer's, 31 Oct).
Record has a card for each activity it records, its Start opening the Record sheet
for that activity, the interval plans below (`ActivityCard`); over Start recording
at once with the sheet's choices, an options button beside it, which hides what a
workout may need set (the developer's, 31 Oct). The sheet no longer chooses the
activity, so it offers the plans and routes for it or for any alone, where one for
another activity had chosen it; while a recording is under way or waits to be saved,
the cards' Start waits, the bar leading back.
## Consequences