feat(train): Record has a card for each activity, its Start opening the sheet for it (ADR 0030, D60)
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:
parent
4c78c7d2c5
commit
f04388225b
11 changed files with 333 additions and 205 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue