feat(train): the Loop's live heart rate in a recording (ADR 0030, D41)
A recording with a Loop set up reads its heart rate through Polar's SDK while no strap sends: the strap first, the Loop where there is none or while it is lost. The SDK is one for the process, held by the open app's bridge and by the recording service, so that the band stays connected with the app gone; the app reopening finds it connected. The recording file gains `loop` state lines; the rate is kept alone, as `h` lines, and the Record sheet names the source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
388b3b1ae6
commit
fa7680821d
15 changed files with 577 additions and 72 deletions
|
|
@ -88,6 +88,8 @@
|
|||
|
||||
"recordHeartRate": "Herzfrequenz",
|
||||
"recordNoStrap": "Kein Brustgurt",
|
||||
"recordHeartLoop": "Vom Polar Loop",
|
||||
"recordHeartStrapThenLoop": "{strap}, der Loop, solange er nicht verbunden ist",
|
||||
"strapSheetTitle": "Brustgurt",
|
||||
"strapSearching": "Suche Brustgurte in der Nähe …",
|
||||
"strapSearchingHint": "Leg den Gurt an: Die meisten wachen erst auf, wenn sie die Haut berühren.",
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@
|
|||
|
||||
"recordHeartRate": "Heart rate",
|
||||
"recordNoStrap": "No strap",
|
||||
"recordHeartLoop": "From the Polar Loop",
|
||||
"recordHeartStrapThenLoop": "{strap}, the Loop while it is lost",
|
||||
"@recordHeartStrapThenLoop": {"placeholders": {"strap": {"type": "String", "example": "Polar H10"}}},
|
||||
"@recordNoStrap": {"description": "On the Record sheet, under Heart rate, where no strap is chosen."},
|
||||
"strapSheetTitle": "Heart-rate strap",
|
||||
"strapSearching": "Looking for straps nearby…",
|
||||
|
|
|
|||
|
|
@ -580,6 +580,18 @@ abstract class AppLocalizations {
|
|||
/// **'No strap'**
|
||||
String get recordNoStrap;
|
||||
|
||||
/// No description provided for @recordHeartLoop.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From the Polar Loop'**
|
||||
String get recordHeartLoop;
|
||||
|
||||
/// No description provided for @recordHeartStrapThenLoop.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{strap}, the Loop while it is lost'**
|
||||
String recordHeartStrapThenLoop(String strap);
|
||||
|
||||
/// No description provided for @strapSheetTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -311,6 +311,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get recordNoStrap => 'Kein Brustgurt';
|
||||
|
||||
@override
|
||||
String get recordHeartLoop => 'Vom Polar Loop';
|
||||
|
||||
@override
|
||||
String recordHeartStrapThenLoop(String strap) {
|
||||
return '$strap, der Loop, solange er nicht verbunden ist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get strapSheetTitle => 'Brustgurt';
|
||||
|
||||
|
|
|
|||
|
|
@ -308,6 +308,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get recordNoStrap => 'No strap';
|
||||
|
||||
@override
|
||||
String get recordHeartLoop => 'From the Polar Loop';
|
||||
|
||||
@override
|
||||
String recordHeartStrapThenLoop(String strap) {
|
||||
return '$strap, the Loop while it is lost';
|
||||
}
|
||||
|
||||
@override
|
||||
String get strapSheetTitle => 'Heart-rate strap';
|
||||
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
|
|||
Localizations.localeOf(context).toString(),
|
||||
live,
|
||||
),
|
||||
loop: () => train.loop?.status.loop?.id,
|
||||
)..add(const RecordingEventOpened()),
|
||||
child: child,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
|||
/// app was gone, the service not), one cut off, or one stopped and not saved.
|
||||
/// 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).
|
||||
/// (D28); their Loop, where they have one, gives the heart rate while no
|
||||
/// strap does (D41).
|
||||
///
|
||||
/// The recorder decides the pauses (D16); this works out what the screen and
|
||||
/// the notification show from the same lines the file holds.
|
||||
|
|
@ -42,6 +43,9 @@ class RecordingBloc(
|
|||
|
||||
/// A second passing, while it records; every second by default.
|
||||
Stream<void>? ticks,
|
||||
|
||||
/// The person's Loop as a recording starts; null where they have none.
|
||||
final String? Function()? loop,
|
||||
}) extends Bloc<RecordingEvent, RecordingState> {
|
||||
this : super(const RecordingState()) {
|
||||
on<RecordingEventOpened>(_onOpened);
|
||||
|
|
@ -181,6 +185,7 @@ class RecordingBloc(
|
|||
notice: notice,
|
||||
strap: event.useStrap ? state.strap?.address : null,
|
||||
rower: rows ? rower?.address : null,
|
||||
loop: loop?.call(),
|
||||
);
|
||||
_shown = _keyOf(notice);
|
||||
_follow();
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
required RecorderNotice notice,
|
||||
String? strap,
|
||||
String? rower,
|
||||
String? loop,
|
||||
}) => _channel.invokeMethod<void>('start', {
|
||||
'path': path,
|
||||
'activity': activity.name,
|
||||
|
|
@ -43,6 +44,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
'notice': noticeJson(notice),
|
||||
'strap': strap,
|
||||
'rower': rower,
|
||||
'loop': loop,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
|||
};
|
||||
final lastFix = recording.fixes.lastOrNull;
|
||||
final strap = recording.straps.lastOrNull?.state;
|
||||
final loop = recording.loops.lastOrNull?.state;
|
||||
final rate = recording.heartRates.lastOrNull;
|
||||
final rows = recording.rowing.isNotEmpty || recording.rowers.isNotEmpty;
|
||||
final lastRowing = recording.rowing.lastOrNull;
|
||||
|
|
@ -69,8 +70,9 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
|||
paused: paused,
|
||||
autoPaused: paused && last!.auto,
|
||||
strap: strap,
|
||||
loop: loop,
|
||||
bpm:
|
||||
strap == LinkState.connected &&
|
||||
(strap == LinkState.connected || loop == LinkState.connected) &&
|
||||
rate != null &&
|
||||
now.difference(rate.time) <= freshHeartRate
|
||||
? rate.bpm
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@ abstract interface class RecorderPlatform() {
|
|||
required RecorderNotice notice,
|
||||
String? strap,
|
||||
String? rower,
|
||||
|
||||
/// The Loop whose heart rate is kept while no strap sends (D41).
|
||||
String? loop,
|
||||
});
|
||||
|
||||
/// Pauses, or goes on, by hand.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const recordingFileMagic = 'halkyon-train-recording';
|
|||
/// strap <time> connecting|connected|lost
|
||||
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
|
||||
/// rower <time> connecting|connected|lost
|
||||
/// loop <time> connecting|connected|lost
|
||||
/// end <time>
|
||||
/// ```
|
||||
///
|
||||
|
|
@ -54,6 +55,7 @@ Recording? readRecording(String text) {
|
|||
final steps = <DateTime>[];
|
||||
final pauses = <PauseMark>[];
|
||||
final straps = <LinkMark>[];
|
||||
final loops = <LinkMark>[];
|
||||
final rowing = <RowingSample>[];
|
||||
final rowers = <LinkMark>[];
|
||||
DateTime? endedAt;
|
||||
|
|
@ -94,11 +96,13 @@ Recording? readRecording(String text) {
|
|||
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
|
||||
);
|
||||
case [final link, _, final name]
|
||||
when link == 'strap' || link == 'rower':
|
||||
when link == 'strap' || link == 'rower' || link == 'loop':
|
||||
if (LinkState.values.asNameMap()[name] case final state?) {
|
||||
(link == 'strap' ? straps : rowers).add(
|
||||
LinkMark(time: time, state: state),
|
||||
);
|
||||
(switch (link) {
|
||||
'strap' => straps,
|
||||
'loop' => loops,
|
||||
_ => rowers,
|
||||
}).add(LinkMark(time: time, state: state));
|
||||
}
|
||||
case ['r', _, final rate, final strokes, final metres, final watts]:
|
||||
rowing.add(
|
||||
|
|
@ -122,6 +126,7 @@ Recording? readRecording(String text) {
|
|||
steps: steps,
|
||||
pauses: pauses,
|
||||
straps: straps,
|
||||
loops: loops,
|
||||
rowing: rowing,
|
||||
rowers: rowers,
|
||||
endedAt: endedAt,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,11 @@ final class const LiveRecording({
|
|||
/// The strap's state; null where it records without one (D27).
|
||||
final LinkState? strap,
|
||||
|
||||
/// The heart's rate now, from a connected strap's last few seconds.
|
||||
/// The Loop's state; null where it records without one (D41).
|
||||
final LinkState? loop,
|
||||
|
||||
/// The heart's rate now, from a connected strap's or Loop's last few
|
||||
/// seconds.
|
||||
final int? bpm,
|
||||
|
||||
/// The rower's state; null where it records without one (D28).
|
||||
|
|
@ -56,6 +60,7 @@ final class const LiveRecording({
|
|||
paused,
|
||||
autoPaused,
|
||||
strap,
|
||||
loop,
|
||||
bpm,
|
||||
rower,
|
||||
split,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ final class const RowingSample({
|
|||
List<Object?> get props => [time, strokeRate, strokes, metres, watts];
|
||||
}
|
||||
|
||||
/// Where the strap or the rower stands (D27, D28), as the recorder sees it.
|
||||
/// Where the strap, the rower or the Loop stands (D27, D28, D41), as the
|
||||
/// recorder sees it.
|
||||
enum LinkState() {
|
||||
/// Asked for, not answering yet: at the start, and out of reach.
|
||||
connecting,
|
||||
|
|
@ -96,6 +97,9 @@ final class const Recording({
|
|||
/// The strap's states, where it was recorded with one (D27).
|
||||
final List<LinkMark> straps = const [],
|
||||
|
||||
/// The Loop's states, where its heart rate was read (D41).
|
||||
final List<LinkMark> loops = const [],
|
||||
|
||||
/// The rower's frames and states (D28).
|
||||
final List<RowingSample> rowing = const [],
|
||||
final List<LinkMark> rowers = const [],
|
||||
|
|
@ -126,6 +130,7 @@ final class const Recording({
|
|||
steps,
|
||||
pauses,
|
||||
straps,
|
||||
loops,
|
||||
rowing,
|
||||
rowers,
|
||||
endedAt,
|
||||
|
|
|
|||
|
|
@ -29,8 +29,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), and for a row the rower, without
|
||||
/// which it doesn't start (D28); true once Start was pressed, the start
|
||||
/// 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].
|
||||
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
|
||||
await showAdaptiveSheet<bool>(
|
||||
|
|
@ -112,14 +113,20 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
buildWhen: (before, now) => before.strap != now.strap,
|
||||
builder: (context, state) {
|
||||
final strap = state.strap;
|
||||
final loop =
|
||||
PersonScope.of<TrainPerson>(context).loop?.status.loop !=
|
||||
null;
|
||||
final strapName = strap?.name ?? l10n.strapUnnamed;
|
||||
return ListTile(
|
||||
leading: const Icon(LucideIcons.heartPulse),
|
||||
title: Text(l10n.recordHeartRate),
|
||||
subtitle: Text(
|
||||
strap == null
|
||||
? l10n.recordNoStrap
|
||||
: strap.name ?? l10n.strapUnnamed,
|
||||
),
|
||||
subtitle: Text(switch ((strap, loop)) {
|
||||
(null, false) => l10n.recordNoStrap,
|
||||
(null, true) => l10n.recordHeartLoop,
|
||||
(_, true) when !_useStrap => l10n.recordHeartLoop,
|
||||
(_, true) => l10n.recordHeartStrapThenLoop(strapName),
|
||||
(_, false) => strapName,
|
||||
}),
|
||||
trailing: strap == null
|
||||
? const Icon(LucideIcons.chevronRight)
|
||||
: Switch(
|
||||
|
|
|
|||
|
|
@ -102,7 +102,10 @@ class const _Recording({required final LiveRecording? live})
|
|||
l10n.recordingRowerConnecting,
|
||||
false,
|
||||
),
|
||||
LiveRecording(strap: LinkState.lost) => (l10n.recordingStrapLost, true),
|
||||
// The Loop sending in its place, the heart rate goes on (D41).
|
||||
LiveRecording(strap: LinkState.lost, :final loop)
|
||||
when loop != LinkState.connected =>
|
||||
(l10n.recordingStrapLost, true),
|
||||
_ => (gps, false),
|
||||
};
|
||||
return Padding(
|
||||
|
|
@ -199,7 +202,7 @@ class const _Recording({required final LiveRecording? live})
|
|||
),
|
||||
],
|
||||
),
|
||||
if (live.strap != null) ...[
|
||||
if (live.strap != null || live.loop != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
_Figure(
|
||||
value: live.bpm?.toString() ?? '–',
|
||||
|
|
|
|||
Loading…
Reference in a new issue