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

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 {