feat(train): a recording runs an interval plan, with cues and Lap (ADR 0030, D42)
The plan chosen on the Record sheet goes to the recording service spelled out: each step's end, its target (zones as the heart rates they span for the profile's highest rate) and its spoken line in the app's language. PlanRunner.kt moves through the steps on the moving time and distance, counts a timed step's last seconds down and cues off target from a 30 s window; PlanCues.kt gives a tone, a vibration and the voice, ducking music. The file's plan and step lines bring it back, and the recording screen shows the step, what is left of it, its target, the next step and Lap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f406ddcc59
commit
d9f98449a8
16 changed files with 1886 additions and 26 deletions
|
|
@ -27,6 +27,13 @@
|
|||
"recordAutoPause": "Von selbst pausieren",
|
||||
"recordAutoPauseHint": "Wenn du stehst, und weiter, sobald du dich wieder bewegst",
|
||||
"recordStart": "Start",
|
||||
"recordPlan": "Plan",
|
||||
"recordNoPlan": "Keiner",
|
||||
"recordingLap": "Runde",
|
||||
"recordingPlanDone": "Plan geschafft",
|
||||
"recordingStepOf": "Schritt {index} von {count}",
|
||||
"recordingNextStep": "Danach: {step}",
|
||||
"recordingStepTapLap": "Bis du auf Runde tippst",
|
||||
"recordLocationRefused": "Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.",
|
||||
"recordingWaitingForGps": "Warte auf GPS",
|
||||
"recordingWaitingForGpsAccuracy": "Warte auf GPS · ±{metres} m",
|
||||
|
|
@ -273,6 +280,22 @@
|
|||
"planDeleteMessage": "Er wird auf all deinen Geräten gelöscht; Trainings, die mit ihm aufgezeichnet wurden, behalten ihre Schritte.",
|
||||
"planDeleted": "Plan gelöscht",
|
||||
"planSteps": "{count, plural, =1{1 Schritt} other{{count} Schritte}}",
|
||||
"planSayRound": "{round} von {rounds}",
|
||||
"planSayMinutes": "{count, plural, =1{1 Minute} other{{count} Minuten}}",
|
||||
"planSaySeconds": "{count, plural, =1{1 Sekunde} other{{count} Sekunden}}",
|
||||
"planSayMetres": "{count, plural, =1{1 Meter} other{{count} Meter}}",
|
||||
"planSayKilometres": "{km} Kilometer",
|
||||
"planSayUntilLap": "bis du auf Runde tippst",
|
||||
"planSayPace": "{from} bis {to} pro Kilometer",
|
||||
"planSaySpeed": "{from} bis {to} Kilometer pro Stunde",
|
||||
"planSayHeartRate": "{from} bis {to} Schläge pro Minute",
|
||||
"planSayZone": "Zone {zone}",
|
||||
"planSaySplit": "{from} bis {to} pro 500 Meter",
|
||||
"planSayWatts": "{from} bis {to} Watt",
|
||||
"planSayStrokeRate": "{from} bis {to} Ruderschläge pro Minute",
|
||||
"planSayHarder": "Etwas mehr",
|
||||
"planSayEasier": "Etwas ruhiger",
|
||||
"planSayDone": "Plan geschafft",
|
||||
"stepTitle": "Schritt",
|
||||
"stepKind": "Art",
|
||||
"stepWarmUp": "Aufwärmen",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,15 @@
|
|||
"@recordAutoPause": {"description": "The switch for auto-pause, as the recording starts."},
|
||||
"recordAutoPauseHint": "When you stand still, and going on as you move again",
|
||||
"recordStart": "Start",
|
||||
"recordPlan": "Plan",
|
||||
"recordNoPlan": "None",
|
||||
"recordingLap": "Lap",
|
||||
"recordingPlanDone": "Plan done",
|
||||
"recordingStepOf": "Step {index} of {count}",
|
||||
"@recordingStepOf": {"placeholders": {"index": {"type": "int"}, "count": {"type": "int"}}},
|
||||
"recordingNextStep": "Next: {step}",
|
||||
"@recordingNextStep": {"placeholders": {"step": {"type": "String"}}},
|
||||
"recordingStepTapLap": "Until you tap Lap",
|
||||
"recordLocationRefused": "Train needs your location to record this. You can allow it in the system's settings.",
|
||||
"recordingWaitingForGps": "Waiting for GPS",
|
||||
"recordingWaitingForGpsAccuracy": "Waiting for GPS · ±{metres} m",
|
||||
|
|
@ -334,6 +343,34 @@
|
|||
"planDeleted": "Plan deleted",
|
||||
"planSteps": "{count, plural, =1{1 step} other{{count} steps}}",
|
||||
"@planSteps": {"placeholders": {"count": {"type": "int"}}},
|
||||
"planSayRound": "{round} of {rounds}",
|
||||
"@planSayRound": {"placeholders": {"round": {"type": "int"}, "rounds": {"type": "int"}}},
|
||||
"planSayMinutes": "{count, plural, =1{1 minute} other{{count} minutes}}",
|
||||
"@planSayMinutes": {"placeholders": {"count": {"type": "int"}}},
|
||||
"planSaySeconds": "{count, plural, =1{1 second} other{{count} seconds}}",
|
||||
"@planSaySeconds": {"placeholders": {"count": {"type": "int"}}},
|
||||
"planSayMetres": "{count, plural, =1{1 metre} other{{count} metres}}",
|
||||
"@planSayMetres": {"placeholders": {"count": {"type": "int"}}},
|
||||
"planSayKilometres": "{km} kilometres",
|
||||
"@planSayKilometres": {"placeholders": {"km": {"type": "String"}}},
|
||||
"planSayUntilLap": "until you tap Lap",
|
||||
"planSayPace": "{from} to {to} a kilometre",
|
||||
"@planSayPace": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"planSaySpeed": "{from} to {to} kilometres an hour",
|
||||
"@planSaySpeed": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"planSayHeartRate": "{from} to {to} beats a minute",
|
||||
"@planSayHeartRate": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"planSayZone": "zone {zone}",
|
||||
"@planSayZone": {"placeholders": {"zone": {"type": "int"}}},
|
||||
"planSaySplit": "{from} to {to} per 500 metres",
|
||||
"@planSaySplit": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"planSayWatts": "{from} to {to} watts",
|
||||
"@planSayWatts": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"planSayStrokeRate": "{from} to {to} strokes a minute",
|
||||
"@planSayStrokeRate": {"placeholders": {"from": {"type": "String"}, "to": {"type": "String"}}},
|
||||
"planSayHarder": "Push a little",
|
||||
"planSayEasier": "Ease off a little",
|
||||
"planSayDone": "Plan done",
|
||||
"stepTitle": "Step",
|
||||
"stepKind": "Kind",
|
||||
"stepWarmUp": "Warm-up",
|
||||
|
|
|
|||
|
|
@ -226,6 +226,48 @@ abstract class AppLocalizations {
|
|||
/// **'Start'**
|
||||
String get recordStart;
|
||||
|
||||
/// No description provided for @recordPlan.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plan'**
|
||||
String get recordPlan;
|
||||
|
||||
/// No description provided for @recordNoPlan.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None'**
|
||||
String get recordNoPlan;
|
||||
|
||||
/// No description provided for @recordingLap.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lap'**
|
||||
String get recordingLap;
|
||||
|
||||
/// No description provided for @recordingPlanDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plan done'**
|
||||
String get recordingPlanDone;
|
||||
|
||||
/// No description provided for @recordingStepOf.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Step {index} of {count}'**
|
||||
String recordingStepOf(int index, int count);
|
||||
|
||||
/// No description provided for @recordingNextStep.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Next: {step}'**
|
||||
String recordingNextStep(String step);
|
||||
|
||||
/// No description provided for @recordingStepTapLap.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Until you tap Lap'**
|
||||
String get recordingStepTapLap;
|
||||
|
||||
/// No description provided for @recordLocationRefused.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1660,6 +1702,102 @@ abstract class AppLocalizations {
|
|||
/// **'{count, plural, =1{1 step} other{{count} steps}}'**
|
||||
String planSteps(int count);
|
||||
|
||||
/// No description provided for @planSayRound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{round} of {rounds}'**
|
||||
String planSayRound(int round, int rounds);
|
||||
|
||||
/// No description provided for @planSayMinutes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 minute} other{{count} minutes}}'**
|
||||
String planSayMinutes(int count);
|
||||
|
||||
/// No description provided for @planSaySeconds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 second} other{{count} seconds}}'**
|
||||
String planSaySeconds(int count);
|
||||
|
||||
/// No description provided for @planSayMetres.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 metre} other{{count} metres}}'**
|
||||
String planSayMetres(int count);
|
||||
|
||||
/// No description provided for @planSayKilometres.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{km} kilometres'**
|
||||
String planSayKilometres(String km);
|
||||
|
||||
/// No description provided for @planSayUntilLap.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'until you tap Lap'**
|
||||
String get planSayUntilLap;
|
||||
|
||||
/// No description provided for @planSayPace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from} to {to} a kilometre'**
|
||||
String planSayPace(String from, String to);
|
||||
|
||||
/// No description provided for @planSaySpeed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from} to {to} kilometres an hour'**
|
||||
String planSaySpeed(String from, String to);
|
||||
|
||||
/// No description provided for @planSayHeartRate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from} to {to} beats a minute'**
|
||||
String planSayHeartRate(String from, String to);
|
||||
|
||||
/// No description provided for @planSayZone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'zone {zone}'**
|
||||
String planSayZone(int zone);
|
||||
|
||||
/// No description provided for @planSaySplit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from} to {to} per 500 metres'**
|
||||
String planSaySplit(String from, String to);
|
||||
|
||||
/// No description provided for @planSayWatts.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from} to {to} watts'**
|
||||
String planSayWatts(String from, String to);
|
||||
|
||||
/// No description provided for @planSayStrokeRate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{from} to {to} strokes a minute'**
|
||||
String planSayStrokeRate(String from, String to);
|
||||
|
||||
/// No description provided for @planSayHarder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Push a little'**
|
||||
String get planSayHarder;
|
||||
|
||||
/// No description provided for @planSayEasier.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ease off a little'**
|
||||
String get planSayEasier;
|
||||
|
||||
/// No description provided for @planSayDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Plan done'**
|
||||
String get planSayDone;
|
||||
|
||||
/// No description provided for @stepTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,31 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get recordStart => 'Start';
|
||||
|
||||
@override
|
||||
String get recordPlan => 'Plan';
|
||||
|
||||
@override
|
||||
String get recordNoPlan => 'Keiner';
|
||||
|
||||
@override
|
||||
String get recordingLap => 'Runde';
|
||||
|
||||
@override
|
||||
String get recordingPlanDone => 'Plan geschafft';
|
||||
|
||||
@override
|
||||
String recordingStepOf(int index, int count) {
|
||||
return 'Schritt $index von $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String recordingNextStep(String step) {
|
||||
return 'Danach: $step';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingStepTapLap => 'Bis du auf Runde tippst';
|
||||
|
||||
@override
|
||||
String get recordLocationRefused =>
|
||||
'Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.';
|
||||
|
|
@ -943,6 +968,96 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayRound(int round, int rounds) {
|
||||
return '$round von $rounds';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayMinutes(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Minuten',
|
||||
one: '1 Minute',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSaySeconds(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Sekunden',
|
||||
one: '1 Sekunde',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayMetres(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Meter',
|
||||
one: '1 Meter',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayKilometres(String km) {
|
||||
return '$km Kilometer';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planSayUntilLap => 'bis du auf Runde tippst';
|
||||
|
||||
@override
|
||||
String planSayPace(String from, String to) {
|
||||
return '$from bis $to pro Kilometer';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSaySpeed(String from, String to) {
|
||||
return '$from bis $to Kilometer pro Stunde';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayHeartRate(String from, String to) {
|
||||
return '$from bis $to Schläge pro Minute';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayZone(int zone) {
|
||||
return 'Zone $zone';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSaySplit(String from, String to) {
|
||||
return '$from bis $to pro 500 Meter';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayWatts(String from, String to) {
|
||||
return '$from bis $to Watt';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayStrokeRate(String from, String to) {
|
||||
return '$from bis $to Ruderschläge pro Minute';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planSayHarder => 'Etwas mehr';
|
||||
|
||||
@override
|
||||
String get planSayEasier => 'Etwas ruhiger';
|
||||
|
||||
@override
|
||||
String get planSayDone => 'Plan geschafft';
|
||||
|
||||
@override
|
||||
String get stepTitle => 'Schritt';
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,31 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get recordStart => 'Start';
|
||||
|
||||
@override
|
||||
String get recordPlan => 'Plan';
|
||||
|
||||
@override
|
||||
String get recordNoPlan => 'None';
|
||||
|
||||
@override
|
||||
String get recordingLap => 'Lap';
|
||||
|
||||
@override
|
||||
String get recordingPlanDone => 'Plan done';
|
||||
|
||||
@override
|
||||
String recordingStepOf(int index, int count) {
|
||||
return 'Step $index of $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String recordingNextStep(String step) {
|
||||
return 'Next: $step';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordingStepTapLap => 'Until you tap Lap';
|
||||
|
||||
@override
|
||||
String get recordLocationRefused =>
|
||||
'Train needs your location to record this. You can allow it in the system\'s settings.';
|
||||
|
|
@ -936,6 +961,96 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayRound(int round, int rounds) {
|
||||
return '$round of $rounds';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayMinutes(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count minutes',
|
||||
one: '1 minute',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSaySeconds(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count seconds',
|
||||
one: '1 second',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayMetres(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count metres',
|
||||
one: '1 metre',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayKilometres(String km) {
|
||||
return '$km kilometres';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planSayUntilLap => 'until you tap Lap';
|
||||
|
||||
@override
|
||||
String planSayPace(String from, String to) {
|
||||
return '$from to $to a kilometre';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSaySpeed(String from, String to) {
|
||||
return '$from to $to kilometres an hour';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayHeartRate(String from, String to) {
|
||||
return '$from to $to beats a minute';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayZone(int zone) {
|
||||
return 'zone $zone';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSaySplit(String from, String to) {
|
||||
return '$from to $to per 500 metres';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayWatts(String from, String to) {
|
||||
return '$from to $to watts';
|
||||
}
|
||||
|
||||
@override
|
||||
String planSayStrokeRate(String from, String to) {
|
||||
return '$from to $to strokes a minute';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planSayHarder => 'Push a little';
|
||||
|
||||
@override
|
||||
String get planSayEasier => 'Ease off a little';
|
||||
|
||||
@override
|
||||
String get planSayDone => 'Plan done';
|
||||
|
||||
@override
|
||||
String get stepTitle => 'Step';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
|
|
@ -7,6 +8,7 @@ import 'package:halkyon_train_flutter/src/app/train_languages.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
|
||||
|
|
@ -132,6 +134,18 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
|
|||
live,
|
||||
),
|
||||
loop: () => train.loop?.status.loop?.id,
|
||||
// Spoken in the app's language, zones from the person's profile.
|
||||
planOf: (plan) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toLanguageTag();
|
||||
final profile = await train.profile.read();
|
||||
return recorderPlanOf(
|
||||
l10n,
|
||||
locale,
|
||||
plan,
|
||||
maxHeartRate: planMaxHeartRate(profile, clock.now()),
|
||||
);
|
||||
},
|
||||
)..add(const RecordingEventOpened()),
|
||||
child: child,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/loop/model/loop_figures.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// The age a highest heart rate is guessed for where the profile gives no
|
||||
/// birth date: 180 bpm by Polar's formula.
|
||||
const _guessedAge = 40;
|
||||
|
||||
/// The highest heart rate a plan's zones are worked out from (D42): the
|
||||
/// profile's, or Polar's guess at the person's age on [today].
|
||||
int planMaxHeartRate(ProfileContent? profile, DateTime today) =>
|
||||
profile?.maxHeartRate ??
|
||||
defaultMaxHeartRate(profile?.ageOn(today) ?? _guessedAge);
|
||||
|
||||
/// [plan] as the recorder runs it (ADR 0030, D42), in the app's language
|
||||
/// [locale] (a BCP 47 tag): `plan`, its payload, which the recording file
|
||||
/// keeps; `steps`, the run spelled out, each with its end (`seconds` or
|
||||
/// `metres`, neither for a lap tap), its target, zones as the heart rates
|
||||
/// they span for [maxHeartRate], and what is said as it starts; `say`, the
|
||||
/// words for off target and for the end.
|
||||
Map<String, Object?> recorderPlanOf(
|
||||
AppLocalizations l10n,
|
||||
String locale,
|
||||
PlanContent plan, {
|
||||
required int maxHeartRate,
|
||||
}) => {
|
||||
'plan': jsonDecode(utf8.decode(Uint8List.sublistView(encodePlan(plan)))),
|
||||
'offTargetCues': plan.offTargetCues,
|
||||
'locale': locale,
|
||||
'steps': [
|
||||
for (final run in plan.run)
|
||||
{
|
||||
...switch (run.step.end) {
|
||||
EndAfterTime(:final time) => {'seconds': time.inSeconds},
|
||||
EndAfterDistance(:final metres) => {'metres': metres},
|
||||
EndWithLap() => const <String, Object?>{},
|
||||
},
|
||||
if (run.step.target case final target?)
|
||||
'target': switch (target.kind) {
|
||||
TargetKind.heartZone => () {
|
||||
final zone = heartZone(target.low.round(), maxHeartRate);
|
||||
return {
|
||||
'kind': TargetKind.heartRate.name,
|
||||
'low': zone.low,
|
||||
'high': zone.high,
|
||||
};
|
||||
}(),
|
||||
_ => {
|
||||
'kind': target.kind.name,
|
||||
'low': target.low,
|
||||
'high': target.high,
|
||||
},
|
||||
},
|
||||
'say': spokenStep(l10n, locale, run),
|
||||
},
|
||||
],
|
||||
'say': {
|
||||
'harder': l10n.planSayHarder,
|
||||
'easier': l10n.planSayEasier,
|
||||
'done': l10n.planSayDone,
|
||||
},
|
||||
};
|
||||
|
||||
/// What is said as [run] starts: `Work, 2 of 4, 4 minutes, zone 4`.
|
||||
String spokenStep(AppLocalizations l10n, String locale, RunStep run) => [
|
||||
stepKindName(l10n, run.step.kind),
|
||||
if (run.rounds > 1) l10n.planSayRound(run.round, run.rounds),
|
||||
_spokenLength(l10n, locale, run.step.end),
|
||||
if (run.step.target case final target?) _spokenTarget(l10n, target),
|
||||
].join(', ');
|
||||
|
||||
String _spokenLength(AppLocalizations l10n, String locale, StepEnd end) =>
|
||||
switch (end) {
|
||||
EndAfterTime(:final time) => switch ((
|
||||
time.inMinutes,
|
||||
time.inSeconds % 60,
|
||||
)) {
|
||||
(0, final seconds) => l10n.planSaySeconds(seconds),
|
||||
(final minutes, 0) => l10n.planSayMinutes(minutes),
|
||||
(final minutes, final seconds) =>
|
||||
'${l10n.planSayMinutes(minutes)} ${l10n.planSaySeconds(seconds)}',
|
||||
},
|
||||
EndAfterDistance(:final metres) =>
|
||||
metres < 1000
|
||||
? l10n.planSayMetres(metres)
|
||||
: l10n.planSayKilometres(
|
||||
NumberFormat.decimalPatternDigits(
|
||||
locale: locale,
|
||||
decimalDigits: metres % 1000 == 0 ? 0 : 1,
|
||||
).format(metres / 1000),
|
||||
),
|
||||
EndWithLap() => l10n.planSayUntilLap,
|
||||
};
|
||||
|
||||
String _spokenTarget(AppLocalizations l10n, Target target) {
|
||||
String whole(double value) => value.round().toString();
|
||||
final (low, high) = (target.low, target.high);
|
||||
return switch (target.kind) {
|
||||
TargetKind.pace => l10n.planSayPace(
|
||||
minutesSeconds(low),
|
||||
minutesSeconds(high),
|
||||
),
|
||||
TargetKind.speed => l10n.planSaySpeed(whole(low), whole(high)),
|
||||
TargetKind.heartRate => l10n.planSayHeartRate(whole(low), whole(high)),
|
||||
TargetKind.heartZone => l10n.planSayZone(low.round()),
|
||||
TargetKind.split => l10n.planSaySplit(
|
||||
minutesSeconds(low),
|
||||
minutesSeconds(high),
|
||||
),
|
||||
TargetKind.watts => l10n.planSayWatts(whole(low), whole(high)),
|
||||
TargetKind.strokeRate => l10n.planSayStrokeRate(whole(low), whole(high)),
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'dart:io';
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||
|
|
@ -23,7 +24,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
|||
/// It remembers the person's heart-rate strap, which a recording uses unless
|
||||
/// it is switched off for it (D27), and their rower, which a row needs
|
||||
/// (D28); their Loop, where they have one, gives the heart rate while no
|
||||
/// strap does (D41).
|
||||
/// strap does (D41). A recording may run an interval plan (D42), which the
|
||||
/// recorder runs, its cues given there.
|
||||
///
|
||||
/// The recorder decides the pauses (D16); this works out what the screen and
|
||||
/// the notification show from the same lines the file holds.
|
||||
|
|
@ -46,12 +48,17 @@ class RecordingBloc(
|
|||
|
||||
/// The person's Loop as a recording starts; null where they have none.
|
||||
final String? Function()? loop,
|
||||
|
||||
/// A plan as the recorder takes it (`recorderPlanOf`), in the app's
|
||||
/// language and with the person's heart rates.
|
||||
final Future<Map<String, Object?>> Function(PlanContent plan)? planOf,
|
||||
}) extends Bloc<RecordingEvent, RecordingState> {
|
||||
this : super(const RecordingState()) {
|
||||
on<RecordingEventOpened>(_onOpened);
|
||||
on<RecordingEventStartRequested>(_onStart);
|
||||
on<RecordingEventPauseRequested>((event, emit) => _platform.pause());
|
||||
on<RecordingEventResumeRequested>((event, emit) => _platform.resume());
|
||||
on<RecordingEventLapRequested>((event, emit) => _platform.lap());
|
||||
on<RecordingEventStopRequested>(_onStop);
|
||||
on<RecordingEventSaveRequested>(_onSave);
|
||||
on<RecordingEventDiscardRequested>(_onDiscard);
|
||||
|
|
@ -174,6 +181,10 @@ class RecordingBloc(
|
|||
emit(state.copyWith(bluetoothRefused: true));
|
||||
return;
|
||||
}
|
||||
final plan = switch ((event.plan, planOf)) {
|
||||
(final plan?, final planOf?) => await planOf(plan),
|
||||
_ => null,
|
||||
};
|
||||
final now = clock.now().toUtc();
|
||||
final notice = _notice(
|
||||
liveOf(Recording(activity: activity, startedAt: now), now),
|
||||
|
|
@ -186,6 +197,7 @@ class RecordingBloc(
|
|||
strap: event.useStrap ? state.strap?.address : null,
|
||||
rower: rows ? rower?.address : null,
|
||||
loop: loop?.call(),
|
||||
plan: plan,
|
||||
);
|
||||
_shown = _keyOf(notice);
|
||||
_follow();
|
||||
|
|
@ -443,11 +455,13 @@ sealed class const RecordingEvent();
|
|||
final class const RecordingEventOpened() extends RecordingEvent;
|
||||
|
||||
/// The person starts recording [activity], pausing by itself where
|
||||
/// [autoPause] (D16), with their strap unless not [useStrap] (D27).
|
||||
/// [autoPause] (D16), with their strap unless not [useStrap] (D27), running
|
||||
/// [plan] where one was chosen (D42).
|
||||
final class const RecordingEventStartRequested(
|
||||
final Activity activity, {
|
||||
final bool autoPause = true,
|
||||
final bool useStrap = true,
|
||||
final PlanContent? plan,
|
||||
}) extends RecordingEvent;
|
||||
|
||||
/// The person chose [device] as their [kind] on this device.
|
||||
|
|
@ -466,6 +480,9 @@ final class const RecordingEventResumeRequested() extends RecordingEvent;
|
|||
|
||||
final class const RecordingEventStopRequested() extends RecordingEvent;
|
||||
|
||||
/// The person tapped Lap: the plan's step ends (D42).
|
||||
final class const RecordingEventLapRequested() extends RecordingEvent;
|
||||
|
||||
/// The recording stopped or cut off is saved as a workout.
|
||||
final class const RecordingEventSaveRequested() extends RecordingEvent;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
String? strap,
|
||||
String? rower,
|
||||
String? loop,
|
||||
Map<String, Object?>? plan,
|
||||
}) => _channel.invokeMethod<void>('start', {
|
||||
'path': path,
|
||||
'activity': activity.name,
|
||||
|
|
@ -45,6 +46,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
'strap': strap,
|
||||
'rower': rower,
|
||||
'loop': loop,
|
||||
'plan': plan == null ? null : jsonEncode(plan),
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -53,6 +55,9 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
@override
|
||||
Future<void> resume() => _channel.invokeMethod<void>('resume');
|
||||
|
||||
@override
|
||||
Future<void> lap() => _channel.invokeMethod<void>('lap');
|
||||
|
||||
@override
|
||||
Future<void> stop() => _channel.invokeMethod<void>('stop');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
|
|
@ -81,5 +82,64 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
|||
split: paused ? null : _splitOf(recording, now),
|
||||
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
|
||||
watts: rowingFresh && !paused ? lastRowing.watts : null,
|
||||
plan: _planOf(recording, now),
|
||||
);
|
||||
}
|
||||
|
||||
/// Where [recording]'s plan stands at [now] (D42), from its last step mark:
|
||||
/// the moving time and the distance since, as the recorder counts them; null
|
||||
/// without a plan, or before its first step.
|
||||
LivePlan? _planOf(Recording recording, DateTime now) {
|
||||
final plan = recording.plan;
|
||||
final mark = recording.planSteps.lastOrNull;
|
||||
if (plan == null || mark == null) {
|
||||
return null;
|
||||
}
|
||||
final run = plan.run;
|
||||
final index = mark.index;
|
||||
if (index == null || index >= run.length) {
|
||||
return LivePlan(name: plan.name, index: run.length, count: run.length);
|
||||
}
|
||||
final step = run[index];
|
||||
final before = _until(recording, mark.time);
|
||||
return LivePlan(
|
||||
name: plan.name,
|
||||
step: step,
|
||||
index: index,
|
||||
count: run.length,
|
||||
timeLeft: switch (step.step.end) {
|
||||
EndAfterTime(:final time) => _atLeastZero(
|
||||
time - (movingTimeOf(recording, now) - movingTimeOf(before, mark.time)),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
metresLeft: switch (step.step.end) {
|
||||
EndAfterDistance(:final metres) =>
|
||||
(metres - ((distanceOf(recording) ?? 0) - (distanceOf(before) ?? 0)))
|
||||
.clamp(0, metres),
|
||||
_ => null,
|
||||
},
|
||||
next: index + 1 < run.length ? run[index + 1] : null,
|
||||
);
|
||||
}
|
||||
|
||||
Duration _atLeastZero(Duration time) => time.isNegative ? Duration.zero : time;
|
||||
|
||||
/// [recording] as it stood at [time]: its fixes, frames and pauses until
|
||||
/// then.
|
||||
Recording _until(Recording recording, DateTime time) => Recording(
|
||||
activity: recording.activity,
|
||||
startedAt: recording.startedAt,
|
||||
fixes: [
|
||||
for (final fix in recording.fixes)
|
||||
if (!fix.time.isAfter(time)) fix,
|
||||
],
|
||||
pauses: [
|
||||
for (final mark in recording.pauses)
|
||||
if (!mark.time.isAfter(time)) mark,
|
||||
],
|
||||
rowing: [
|
||||
for (final sample in recording.rowing)
|
||||
if (!sample.time.isAfter(time)) sample,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -102,12 +102,19 @@ abstract interface class RecorderPlatform() {
|
|||
|
||||
/// The Loop whose heart rate is kept while no strap sends (D41).
|
||||
String? loop,
|
||||
|
||||
/// The interval plan it runs, as the recorder takes it (D42,
|
||||
/// `recorderPlanOf`).
|
||||
Map<String, Object?>? plan,
|
||||
});
|
||||
|
||||
/// Pauses, or goes on, by hand.
|
||||
Future<void> pause();
|
||||
Future<void> resume();
|
||||
|
||||
/// The person tapped Lap: the plan's step ends (D42).
|
||||
Future<void> lap();
|
||||
|
||||
/// Ends the recording: its file is finished and closed when this completes.
|
||||
Future<void> stop();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
|
|
@ -23,6 +27,8 @@ const recordingFileMagic = 'halkyon-train-recording';
|
|||
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
|
||||
/// rower <time> connecting|connected|lost
|
||||
/// loop <time> connecting|connected|lost
|
||||
/// plan <time> <the plan's payload, JSON>
|
||||
/// step <time> <its index in the plan's run>|done
|
||||
/// end <time>
|
||||
/// ```
|
||||
///
|
||||
|
|
@ -58,6 +64,8 @@ Recording? readRecording(String text) {
|
|||
final loops = <LinkMark>[];
|
||||
final rowing = <RowingSample>[];
|
||||
final rowers = <LinkMark>[];
|
||||
PlanContent? plan;
|
||||
final planSteps = <StepMark>[];
|
||||
DateTime? endedAt;
|
||||
for (final line in lines.skip(1)) {
|
||||
final fields = line.split(' ');
|
||||
|
|
@ -65,7 +73,22 @@ Recording? readRecording(String text) {
|
|||
if (time == null) {
|
||||
continue;
|
||||
}
|
||||
if (fields.first == 'plan' && fields.length > 2) {
|
||||
// Its JSON may hold spaces: all after the time.
|
||||
plan = decodePlan(
|
||||
ByteData.sublistView(
|
||||
utf8.encode(line.substring('plan '.length + fields[1].length + 1)),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
switch (fields) {
|
||||
case ['step', _, 'done']:
|
||||
planSteps.add(StepMark(time: time));
|
||||
case ['step', _, final index]:
|
||||
if (int.tryParse(index) case final index? when index >= 0) {
|
||||
planSteps.add(StepMark(time: time, index: index));
|
||||
}
|
||||
case ['p', _, final lat, final lon, final accuracy, final altitude]:
|
||||
final (latE7, lonE7, accuracyCm) = (
|
||||
int.tryParse(lat),
|
||||
|
|
@ -129,6 +152,8 @@ Recording? readRecording(String text) {
|
|||
loops: loops,
|
||||
rowing: rowing,
|
||||
rowers: rowers,
|
||||
plan: plan,
|
||||
planSteps: planSteps,
|
||||
endedAt: endedAt,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,40 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// Where a recording's plan stands (D42): the step running, which of how
|
||||
/// many, what is left of it, and the next; no step once the plan is done.
|
||||
final class const LivePlan({
|
||||
required final String name,
|
||||
|
||||
/// The step running, its index in the plan's run; null once done.
|
||||
final RunStep? step,
|
||||
final int index = 0,
|
||||
required final int count,
|
||||
|
||||
/// What is left of a timed step, of a measured one; neither for a step
|
||||
/// ending with a lap tap.
|
||||
final Duration? timeLeft,
|
||||
final int? metresLeft,
|
||||
|
||||
/// The step after this one; null for the last.
|
||||
final RunStep? next,
|
||||
}) with Equatable {
|
||||
bool get done => step == null;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
name,
|
||||
step,
|
||||
index,
|
||||
count,
|
||||
timeLeft,
|
||||
metresLeft,
|
||||
next,
|
||||
];
|
||||
}
|
||||
|
||||
/// What the recording screen shows of a recording under way (ADR 0030, D21).
|
||||
final class const LiveRecording({
|
||||
required final Activity activity,
|
||||
|
|
@ -47,6 +80,9 @@ final class const LiveRecording({
|
|||
final Duration? split,
|
||||
final double? strokeRate,
|
||||
final int? watts,
|
||||
|
||||
/// The plan it runs; null without one (D42).
|
||||
final LivePlan? plan,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
|
|
@ -66,5 +102,6 @@ final class const LiveRecording({
|
|||
split,
|
||||
strokeRate,
|
||||
watts,
|
||||
plan,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// A position the phone delivered (ADR 0030, D20: every one, with its
|
||||
|
|
@ -80,6 +81,16 @@ final class const PauseMark({
|
|||
List<Object?> get props => [time, paused, auto];
|
||||
}
|
||||
|
||||
/// A step of the recording's plan starting at [time] (D42): [index] in the
|
||||
/// plan's run ([PlanContent.run]); null where the plan was done.
|
||||
final class const StepMark({
|
||||
required final DateTime time,
|
||||
final int? index,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [time, index];
|
||||
}
|
||||
|
||||
/// A recording as its file holds it (D18): what the recorder took, in the
|
||||
/// order it took it, finished or cut off.
|
||||
final class const Recording({
|
||||
|
|
@ -104,6 +115,10 @@ final class const Recording({
|
|||
final List<RowingSample> rowing = const [],
|
||||
final List<LinkMark> rowers = const [],
|
||||
|
||||
/// The interval plan it ran, where it ran one, and its steps' starts (D42).
|
||||
final PlanContent? plan,
|
||||
final List<StepMark> planSteps = const [],
|
||||
|
||||
/// When the person stopped it; null for one cut off (D18).
|
||||
final DateTime? endedAt,
|
||||
}) with Equatable {
|
||||
|
|
@ -133,6 +148,8 @@ final class const Recording({
|
|||
loops,
|
||||
rowing,
|
||||
rowers,
|
||||
plan,
|
||||
planSteps,
|
||||
endedAt,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/device_sheet.dart';
|
||||
|
|
@ -30,9 +33,9 @@ bool autoPausesByDefault(Activity activity) =>
|
|||
/// The sheet a recording starts from (D21): the activities, the one last
|
||||
/// recorded chosen, 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), and for a row the rower, without which it doesn't start
|
||||
/// (D28); true once Start was pressed, the start
|
||||
/// asked of [bloc].
|
||||
/// heart rate (D41), for a row the rower, without which it doesn't start
|
||||
/// (D28), and the interval plan to run, none by default (D42); true once
|
||||
/// Start was pressed, the start asked of [bloc].
|
||||
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
|
||||
await showAdaptiveSheet<bool>(
|
||||
context,
|
||||
|
|
@ -56,6 +59,9 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
/// Whether this workout records with the strap, where there is one.
|
||||
var _useStrap = true;
|
||||
|
||||
/// The plan it runs; none by default.
|
||||
PlanContent? _plan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -137,6 +143,26 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.listOrdered),
|
||||
title: Text(l10n.recordPlan),
|
||||
subtitle: Text(_plan?.name ?? l10n.recordNoPlan),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: () async {
|
||||
final chosen = await _choosePlan(context, _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)) {
|
||||
_activity = activity;
|
||||
_autoPause = autoPausesByDefault(activity);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
footer: BlocBuilder<RecordingBloc, RecordingState>(
|
||||
|
|
@ -152,6 +178,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
_activity,
|
||||
autoPause: _autoPause,
|
||||
useStrap: _useStrap,
|
||||
plan: _plan,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
|
|
@ -171,6 +198,44 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
);
|
||||
}
|
||||
|
||||
/// The plans to choose from, with None; null where the sheet was left
|
||||
/// without a choice.
|
||||
Future<({PlanContent? plan})?> _choosePlan(
|
||||
BuildContext context,
|
||||
PlanContent? current,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
return showAdaptiveSheet<({PlanContent? plan})>(
|
||||
context,
|
||||
builder: (context) => SheetScaffold(
|
||||
title: l10n.recordPlan,
|
||||
// Read as it opens, where the plans tab wasn't yet.
|
||||
body: BlocBuilder<PlansCubit, PlansState>(
|
||||
builder: (context, state) => RadioGroup<PlanContent?>(
|
||||
groupValue: current,
|
||||
onChanged: (plan) => Navigator.of(context).pop((plan: plan)),
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<PlanContent?>(
|
||||
value: null,
|
||||
title: Text(l10n.recordNoPlan),
|
||||
),
|
||||
for (final stored in state.visible)
|
||||
if (stored.content case final plan?)
|
||||
RadioListTile<PlanContent?>(
|
||||
value: plan,
|
||||
title: Text(plan.name),
|
||||
subtitle: Text(planSummary(l10n, locale, plan)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// [bloc] where its start ended: recording, refused location or nearby
|
||||
/// devices, or without a rower.
|
||||
Future<RecordingState> startedOrRefused(RecordingBloc bloc) =>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
||||
|
|
@ -26,8 +28,8 @@ Future<void> openRecordingScreen(BuildContext context) {
|
|||
}
|
||||
|
||||
/// A recording under way, or stopped (ADR 0030, D21): the moving time, the
|
||||
/// distance and the pace now, GPS's state, Pause or Resume and Stop held
|
||||
/// down; once stopped, Save or Discard. It closes once the recording is
|
||||
/// distance and the pace now, GPS's state, the plan's step and Lap where it
|
||||
/// runs one (D42), Pause or Resume and Stop held down; once stopped, Save or Discard. It closes once the recording is
|
||||
/// saved or discarded; back leaves the recording running.
|
||||
class const RecordingScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -121,6 +123,10 @@ class const _Recording({required final LiveRecording? live})
|
|||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (live.plan case final plan?) ...[
|
||||
const SizedBox(height: 16),
|
||||
_PlanPanel(plan: plan),
|
||||
],
|
||||
const Spacer(),
|
||||
_Figure(
|
||||
value: clockTime(live.movingTime),
|
||||
|
|
@ -210,6 +216,16 @@ class const _Recording({required final LiveRecording? live})
|
|||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (live.plan case LivePlan(done: false)) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.read<RecordingBloc>().add(
|
||||
const RecordingEventLapRequested(),
|
||||
),
|
||||
icon: const Icon(LucideIcons.flag),
|
||||
label: Text(l10n.recordingLap),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Row(
|
||||
// Level with Stop, whose hint shows above it.
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
|
|
@ -241,6 +257,74 @@ class const _Recording({required final LiveRecording? live})
|
|||
}
|
||||
}
|
||||
|
||||
/// The plan's step: its kind and round, what is left of it, its target,
|
||||
/// and the step after it; once done, that it is.
|
||||
class const _PlanPanel({required final LivePlan plan}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
String name(RunStep run) => [
|
||||
stepKindName(l10n, run.step.kind),
|
||||
if (run.rounds > 1) l10n.planSayRound(run.round, run.rounds),
|
||||
].join(' · ');
|
||||
final run = plan.step;
|
||||
return Card.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: run == null
|
||||
? Text(
|
||||
l10n.recordingPlanDone,
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Text(
|
||||
'${plan.name} · '
|
||||
'${l10n.recordingStepOf(plan.index + 1, plan.count)}',
|
||||
style: muted,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(name(run), style: theme.textTheme.titleLarge),
|
||||
Text(
|
||||
switch (plan) {
|
||||
LivePlan(:final timeLeft?) => clockTime(timeLeft),
|
||||
LivePlan(:final metresLeft?) => l10n.stepMetres(
|
||||
metresLeft,
|
||||
),
|
||||
_ => l10n.recordingStepTapLap,
|
||||
},
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
if (run.step.target case final target?)
|
||||
Text(
|
||||
targetText(l10n, target),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
if (plan.next case final next?) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.recordingNextStep(
|
||||
'${name(next)}, '
|
||||
'${stepLength(l10n, locale, next.step.end)}',
|
||||
),
|
||||
style: muted,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop, held down so that no pocket ends a run (D21); a tap says so above
|
||||
/// it for a few seconds, where nothing covers the button.
|
||||
class const _StopButton() extends StatefulWidget {
|
||||
|
|
|
|||
Loading…
Reference in a new issue