feat(train): the heart-rate strap, recorded by the service itself (ADR 0030, D25-D27)

The Record sheet gains a Heart rate row: a sheet looks for straps
advertising the Heart Rate Service, the nearest first, once nearby
devices are allowed, and the one chosen is remembered in the person's
folder; it records with every workout unless switched off for one.
The recorder service connects to it with Android's own Bluetooth API,
no package (D26), parses Heart Rate Measurement in Kotlin (tested on
the JVM), writes each rate and the strap's state to the recording
file, and asks for a lost strap again for as long as it records. The
recording screen shows the heart rate, and "Strap lost" while it is
gone; a workout's page shows the heart's average and highest rate,
each split's average and its chart.

iOS's recording waits for a Mac (D25), after the rower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 15:50:36 +01:00
commit de5fd43e06
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
22 changed files with 1782 additions and 42 deletions

View file

@ -82,5 +82,23 @@
"workoutMapSource": "Karte von {name}: sieht die Adresse deines Geräts und den gezeigten Ausschnitt, nicht deine Strecke.",
"workoutMapSourceAbout": "Was {name} speichert",
"workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.",
"workoutNotFound": "Dieses Training gibt es nicht mehr."
"workoutNotFound": "Dieses Training gibt es nicht mehr.",
"recordHeartRate": "Herzfrequenz",
"recordNoStrap": "Kein Brustgurt",
"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.",
"strapNear": "ganz nah",
"strapFar": "weiter weg",
"strapUnnamed": "Brustgurt ohne Namen",
"strapForget": "Brustgurt vergessen",
"strapRefused": "Train braucht Geräte in der Nähe, um deinen Brustgurt zu finden. Du kannst das in den Systemeinstellungen erlauben.",
"strapBluetoothOff": "Bluetooth ist aus. Schalte es ein, um deinen Brustgurt zu finden.",
"recordingHeartRate": "Herzfrequenz",
"recordingStrapLost": "Brustgurt nicht verbunden",
"workoutAverageHeartRate": "mittlere Herzfrequenz",
"workoutMaxHeartRate": "höchste Herzfrequenz",
"workoutBpmValue": "{bpm} bpm",
"workoutHeartRateChart": "Herzfrequenz"
}

View file

@ -104,5 +104,32 @@
"workoutMapSourceAbout": "What {name} keeps",
"@workoutMapSourceAbout": {"description": "A link to the map source's page about what it keeps.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}},
"workoutMapUnavailable": "The map can't be loaded now.",
"workoutNotFound": "This workout is gone."
"workoutNotFound": "This workout is gone.",
"recordHeartRate": "Heart rate",
"recordNoStrap": "No strap",
"@recordNoStrap": {"description": "On the Record sheet, under Heart rate, where no strap is chosen."},
"strapSheetTitle": "Heart-rate strap",
"strapSearching": "Looking for straps nearby…",
"strapSearchingHint": "Put the strap on: most wake up only once they touch skin.",
"strapNear": "close by",
"@strapNear": {"description": "Under a strap found: its signal is strong."},
"strapFar": "further off",
"@strapFar": {"description": "Under a strap found: its signal is weak."},
"strapUnnamed": "Unnamed strap",
"@strapUnnamed": {"description": "A strap that sends no name."},
"strapForget": "Forget strap",
"strapRefused": "Train needs nearby devices to find your strap. You can allow it in the system's settings.",
"strapBluetoothOff": "Bluetooth is off. Turn it on to find your strap.",
"recordingHeartRate": "heart rate",
"@recordingHeartRate": {"description": "Under the heart rate, lower case."},
"recordingStrapLost": "Strap lost",
"@recordingStrapLost": {"description": "While recording: the strap is out of reach or off; Train keeps asking for it."},
"workoutAverageHeartRate": "average heart rate",
"@workoutAverageHeartRate": {"description": "Lower case, under a figure."},
"workoutMaxHeartRate": "highest heart rate",
"@workoutMaxHeartRate": {"description": "Lower case, under a figure."},
"workoutBpmValue": "{bpm} bpm",
"@workoutBpmValue": {"placeholders": {"bpm": {"type": "int"}}},
"workoutHeartRateChart": "Heart rate"
}

View file

@ -555,6 +555,108 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'This workout is gone.'**
String get workoutNotFound;
/// No description provided for @recordHeartRate.
///
/// In en, this message translates to:
/// **'Heart rate'**
String get recordHeartRate;
/// On the Record sheet, under Heart rate, where no strap is chosen.
///
/// In en, this message translates to:
/// **'No strap'**
String get recordNoStrap;
/// No description provided for @strapSheetTitle.
///
/// In en, this message translates to:
/// **'Heart-rate strap'**
String get strapSheetTitle;
/// No description provided for @strapSearching.
///
/// In en, this message translates to:
/// **'Looking for straps nearby…'**
String get strapSearching;
/// No description provided for @strapSearchingHint.
///
/// In en, this message translates to:
/// **'Put the strap on: most wake up only once they touch skin.'**
String get strapSearchingHint;
/// Under a strap found: its signal is strong.
///
/// In en, this message translates to:
/// **'close by'**
String get strapNear;
/// Under a strap found: its signal is weak.
///
/// In en, this message translates to:
/// **'further off'**
String get strapFar;
/// A strap that sends no name.
///
/// In en, this message translates to:
/// **'Unnamed strap'**
String get strapUnnamed;
/// No description provided for @strapForget.
///
/// In en, this message translates to:
/// **'Forget strap'**
String get strapForget;
/// No description provided for @strapRefused.
///
/// In en, this message translates to:
/// **'Train needs nearby devices to find your strap. You can allow it in the system\'s settings.'**
String get strapRefused;
/// No description provided for @strapBluetoothOff.
///
/// In en, this message translates to:
/// **'Bluetooth is off. Turn it on to find your strap.'**
String get strapBluetoothOff;
/// Under the heart rate, lower case.
///
/// In en, this message translates to:
/// **'heart rate'**
String get recordingHeartRate;
/// While recording: the strap is out of reach or off; Train keeps asking for it.
///
/// In en, this message translates to:
/// **'Strap lost'**
String get recordingStrapLost;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'average heart rate'**
String get workoutAverageHeartRate;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'highest heart rate'**
String get workoutMaxHeartRate;
/// No description provided for @workoutBpmValue.
///
/// In en, this message translates to:
/// **'{bpm} bpm'**
String workoutBpmValue(int bpm);
/// No description provided for @workoutHeartRateChart.
///
/// In en, this message translates to:
/// **'Heart rate'**
String get workoutHeartRateChart;
}
class _AppLocalizationsDelegate

View file

@ -298,4 +298,60 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get workoutNotFound => 'Dieses Training gibt es nicht mehr.';
@override
String get recordHeartRate => 'Herzfrequenz';
@override
String get recordNoStrap => 'Kein Brustgurt';
@override
String get strapSheetTitle => 'Brustgurt';
@override
String get strapSearching => 'Suche Brustgurte in der Nähe …';
@override
String get strapSearchingHint =>
'Leg den Gurt an: Die meisten wachen erst auf, wenn sie die Haut berühren.';
@override
String get strapNear => 'ganz nah';
@override
String get strapFar => 'weiter weg';
@override
String get strapUnnamed => 'Brustgurt ohne Namen';
@override
String get strapForget => 'Brustgurt vergessen';
@override
String get strapRefused =>
'Train braucht Geräte in der Nähe, um deinen Brustgurt zu finden. Du kannst das in den Systemeinstellungen erlauben.';
@override
String get strapBluetoothOff =>
'Bluetooth ist aus. Schalte es ein, um deinen Brustgurt zu finden.';
@override
String get recordingHeartRate => 'Herzfrequenz';
@override
String get recordingStrapLost => 'Brustgurt nicht verbunden';
@override
String get workoutAverageHeartRate => 'mittlere Herzfrequenz';
@override
String get workoutMaxHeartRate => 'höchste Herzfrequenz';
@override
String workoutBpmValue(int bpm) {
return '$bpm bpm';
}
@override
String get workoutHeartRateChart => 'Herzfrequenz';
}

View file

@ -295,6 +295,62 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workoutNotFound => 'This workout is gone.';
@override
String get recordHeartRate => 'Heart rate';
@override
String get recordNoStrap => 'No strap';
@override
String get strapSheetTitle => 'Heart-rate strap';
@override
String get strapSearching => 'Looking for straps nearby…';
@override
String get strapSearchingHint =>
'Put the strap on: most wake up only once they touch skin.';
@override
String get strapNear => 'close by';
@override
String get strapFar => 'further off';
@override
String get strapUnnamed => 'Unnamed strap';
@override
String get strapForget => 'Forget strap';
@override
String get strapRefused =>
'Train needs nearby devices to find your strap. You can allow it in the system\'s settings.';
@override
String get strapBluetoothOff =>
'Bluetooth is off. Turn it on to find your strap.';
@override
String get recordingHeartRate => 'heart rate';
@override
String get recordingStrapLost => 'Strap lost';
@override
String get workoutAverageHeartRate => 'average heart rate';
@override
String get workoutMaxHeartRate => 'highest heart rate';
@override
String workoutBpmValue(int bpm) {
return '$bpm bpm';
}
@override
String get workoutHeartRateChart => 'Heart rate';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -107,6 +107,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
recording.platform,
train.workouts,
recording.file,
recording.straps,
notice: (live) => noticeOf(
AppLocalizations.of(context),
Localizations.localeOf(context).toString(),

View file

@ -12,6 +12,7 @@ import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -21,9 +22,13 @@ import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// Where a device records workouts (ADR 0030, D13, D18): its recorder, and
/// the person's recording file in their folder.
typedef RecordingDevice = ({RecorderPlatform platform, File file});
/// Where a device records workouts (ADR 0030, D13, D18, D27): its recorder,
/// and the person's recording file and strap in their folder.
typedef RecordingDevice = ({
RecorderPlatform platform,
File file,
StrapStore straps,
});
/// A signed-in person's dependencies: who they are and their workouts, on
/// their own local database in their folder, which the sync engine keeps in
@ -293,6 +298,7 @@ Future<TrainPerson> _openPersonData(
? (
platform: AndroidRecorderPlatform(),
file: File(p.join(folder.path, 'recording.txt')),
straps: StrapStore(File(p.join(folder.path, 'strap.json'))),
)
: null,
onDispose: () async {

View file

@ -9,6 +9,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.
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/data/recording_file.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
@ -19,6 +20,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// (D18), follows its lines, and saves the recording as a workout or throws
/// it away once stopped. As it opens, it finds a recording still running (the
/// 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).
///
/// The recorder decides the pauses (D16); this works out what the screen and
/// the notification show from the same lines the file holds.
@ -27,7 +30,10 @@ class RecordingBloc(
final WorkoutRepository _workouts,
/// The person's recording file, in their folder.
final File _file, {
final File _file,
/// The person's strap on this device, in their folder.
final StrapStore _straps, {
/// What the notification shows of [LiveRecording], in the app's language.
required final RecorderNotice Function(LiveRecording live) notice,
@ -43,6 +49,14 @@ class RecordingBloc(
on<RecordingEventStopRequested>(_onStop);
on<RecordingEventSaveRequested>(_onSave);
on<RecordingEventDiscardRequested>(_onDiscard);
on<RecordingEventStrapChosen>((event, emit) {
_straps.save(event.strap);
emit(state._withStrap(event.strap));
});
on<RecordingEventStrapForgotten>((event, emit) {
_straps.forget();
emit(state._withStrap(null));
});
on<RecordingEventLineWritten>((event, emit) {
_lines.writeln(event.line);
// A pause, its end or the end show at once; samples with the next
@ -73,12 +87,14 @@ class RecordingBloc(
Emitter<RecordingState> emit,
) async {
final lastActivity = await _lastActivity();
final strap = _straps.read();
if (await _platform.isRecording()) {
_follow();
emit(
RecordingState(
phase: RecordingPhase.recording,
lastActivity: lastActivity,
strap: strap,
),
);
return;
@ -93,6 +109,7 @@ class RecordingBloc(
},
lastActivity: lastActivity,
recording: kept,
strap: strap,
),
);
}
@ -132,6 +149,7 @@ class RecordingBloc(
activity: activity,
autoPause: autoPause,
notice: notice,
strap: event.useStrap ? state.strap?.address : null,
);
_shown = _keyOf(notice);
_follow();
@ -140,6 +158,7 @@ class RecordingBloc(
phase: RecordingPhase.recording,
lastActivity: activity,
live: liveOf(Recording(activity: activity, startedAt: now), now),
strap: state.strap,
),
);
}
@ -156,6 +175,7 @@ class RecordingBloc(
phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped,
lastActivity: state.lastActivity,
recording: recording,
strap: state.strap,
),
);
}
@ -182,6 +202,7 @@ class RecordingBloc(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: recording.activity,
strap: state.strap,
),
);
}
@ -195,6 +216,7 @@ class RecordingBloc(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: state.lastActivity,
strap: state.strap,
),
);
}
@ -314,6 +336,9 @@ final class const RecordingState({
/// Whether saving failed (as on a device without the account's keys).
final bool saveFailed = false,
/// The person's strap on this device, if they chose one (D27).
final Strap? strap,
}) with Equatable {
RecordingState copyWith({
LiveRecording? live,
@ -326,6 +351,17 @@ final class const RecordingState({
recording: recording,
locationRefused: locationRefused ?? this.locationRefused,
saveFailed: saveFailed ?? this.saveFailed,
strap: strap,
);
RecordingState _withStrap(Strap? strap) => RecordingState(
phase: phase,
lastActivity: lastActivity,
live: live,
recording: recording,
locationRefused: locationRefused,
saveFailed: saveFailed,
strap: strap,
);
@override
@ -336,6 +372,7 @@ final class const RecordingState({
recording,
locationRefused,
saveFailed,
strap,
];
}
@ -346,12 +383,20 @@ sealed class const RecordingEvent();
final class const RecordingEventOpened() extends RecordingEvent;
/// The person starts recording [activity], pausing by itself where
/// [autoPause] (D16).
/// [autoPause] (D16), with their strap unless not [useStrap] (D27).
final class const RecordingEventStartRequested(
final Activity activity, {
final bool autoPause = true,
final bool useStrap = true,
}) extends RecordingEvent;
/// The person chose [strap] to record with on this device.
final class const RecordingEventStrapChosen(final Strap strap)
extends RecordingEvent;
/// The person forgot their strap on this device.
final class const RecordingEventStrapForgotten() extends RecordingEvent;
final class const RecordingEventPauseRequested() extends RecordingEvent;
final class const RecordingEventResumeRequested() extends RecordingEvent;

View file

@ -0,0 +1,89 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
/// Looks for heart-rate straps nearby (ADR 0030, D27), once nearby devices
/// are allowed (asked first, as D19 asks when a thing is first needed), until
/// it is closed.
class StrapSearchCubit(final RecorderPlatform _platform)
extends Cubit<StrapSearchState> {
this : super(const StrapSearchState());
StreamSubscription<FoundStrap>? _scan;
Future<void> open() async {
final granted = await _platform.ask({RecorderPermission.bluetooth});
if (isClosed) {
return;
}
if (!granted.contains(RecorderPermission.bluetooth)) {
emit(const StrapSearchState(phase: StrapSearchPhase.refused));
return;
}
emit(const StrapSearchState(phase: StrapSearchPhase.searching));
_scan = _platform.scanStraps().listen(
_seen,
onError: (Object error) {
if (error is BluetoothOff && !isClosed) {
emit(const StrapSearchState(phase: StrapSearchPhase.bluetoothOff));
}
},
);
}
void _seen(FoundStrap strap) {
final before = [
for (final found in state.found)
if (found.address == strap.address) found,
].firstOrNull;
final seen = FoundStrap(
address: strap.address,
// Some straps send their name in one advertisement and not the next.
name: strap.name ?? before?.name,
rssi: strap.rssi,
);
emit(
StrapSearchState(
phase: state.phase,
found: [
for (final found in state.found)
if (found.address != strap.address) found,
seen,
]..sort((a, b) => b.rssi.compareTo(a.rssi)),
),
);
}
@override
Future<void> close() async {
await _scan?.cancel();
await super.close();
}
}
/// Where the search stands.
enum StrapSearchPhase() {
/// Asking for nearby devices.
asking,
/// Looking: what it found so far.
searching,
/// Nearby devices refused.
refused,
/// Bluetooth off.
bluetoothOff,
}
final class const StrapSearchState({
final StrapSearchPhase phase = StrapSearchPhase.asking,
/// The straps seen, the nearest first.
final List<FoundStrap> found = const [],
}) with Equatable {
@override
List<Object?> get props => [phase, found];
}

View file

@ -7,10 +7,12 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Recording on Android, through the app's own `RecorderService` (Kotlin,
/// `android/app/src/main/kotlin/`, ADR 0030, D13): LocationManager's GPS and
/// the step detector in a foreground service, auto-pause decided there, the
/// file appended to there.
/// file appended to there, and the heart-rate strap found and read there
/// (D26).
class AndroidRecorderPlatform() implements RecorderPlatform {
static const _channel = MethodChannel('app.halkyon.train/recorder');
static const _lines = EventChannel('app.halkyon.train/recorder/lines');
static const _straps = EventChannel('app.halkyon.train/recorder/straps');
@override
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted) async {
@ -29,11 +31,13 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
required Activity activity,
required AutoPauseBy? autoPause,
required RecorderNotice notice,
String? strap,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
'autoPause': autoPause?.name,
'notice': noticeJson(notice),
'strap': strap,
});
@override
@ -55,6 +59,30 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
@override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();
/// Each strap seen as `{address, name, rssi}`; the error `off` where
/// Bluetooth is.
@override
Stream<FoundStrap> scanStraps() => _straps
.receiveBroadcastStream()
.handleError(
(Object error) => throw BluetoothOff(),
test: (error) => error is PlatformException && error.code == 'off',
)
.map(
(event) => switch (event) {
{'address': final String address, 'rssi': final int rssi} =>
FoundStrap(
address: address,
name: switch (event['name']) {
final String name when name.isNotEmpty => name,
_ => null,
},
rssi: rssi,
),
_ => throw FormatException('A strap not read: $event'),
},
);
}
/// [notice] as the service reads it, JSON: times in milliseconds, since 1970

View file

@ -6,6 +6,10 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Whether [activity] is recorded with GPS: all but rowing indoors.
bool usesGps(Activity activity) => activity != Activity.indoorRowing;
/// How old a heart rate may be and still show: a strap sends about once a
/// second.
const freshHeartRate = Duration(seconds: 5);
/// What the recording screen shows of [recording] at [now] (ADR 0030, D21),
/// worked out as a saved workout's totals are (D16, D20).
LiveRecording liveOf(Recording recording, DateTime now) {
@ -21,6 +25,8 @@ LiveRecording liveOf(Recording recording, DateTime now) {
_ => (null, null),
};
final lastFix = recording.fixes.lastOrNull;
final strap = recording.straps.lastOrNull?.state;
final rate = recording.heartRates.lastOrNull;
return LiveRecording(
activity: recording.activity,
movingTime: movingTimeOf(recording, now),
@ -35,5 +41,12 @@ LiveRecording liveOf(Recording recording, DateTime now) {
gps && !recording.fixes.any((fix) => fix.accuracyCm <= maxAccuracyCm),
paused: paused,
autoPaused: paused && last!.auto,
strap: strap,
bpm:
strap == StrapState.connected &&
rate != null &&
now.difference(rate.time) <= freshHeartRate
? rate.bpm
: null,
);
}

View file

@ -11,8 +11,26 @@ enum RecorderPermission() {
/// Physical activity, for the step detector's auto-pause on foot (D16).
activity,
/// Nearby devices, for finding and connecting to a strap (D27; Android 12
/// on, location before).
bluetooth,
}
/// A heart-rate strap seen nearby (D27): its address, its name where it
/// sends one, and how strong its signal is, in dBm.
final class const FoundStrap({
required final String address,
final String? name,
required final int rssi,
}) with Equatable {
@override
List<Object?> get props => [address, name, rssi];
}
/// Bluetooth is off, so nothing can be found.
final class BluetoothOff() implements Exception;
/// How a recording pauses by itself (D16), decided by the recorder.
enum AutoPauseBy() {
/// On foot: by steps, or by GPS speed where steps aren't allowed (D19).
@ -60,12 +78,14 @@ abstract interface class RecorderPlatform() {
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted);
/// Starts recording [activity] into the file at [path], pausing by itself
/// by [autoPause] (null: never).
/// by [autoPause] (null: never), with the heart-rate strap at [strap]
/// (null: none).
Future<void> start({
required String path,
required Activity activity,
required AutoPauseBy? autoPause,
required RecorderNotice notice,
String? strap,
});
/// Pauses, or goes on, by hand.
@ -84,4 +104,8 @@ abstract interface class RecorderPlatform() {
/// Every line of the file written so far, then each new one as it is
/// written, while a recording runs.
Stream<String> lines();
/// The heart-rate straps nearby, each as it is seen, until the stream is
/// cancelled; [BluetoothOff] where it is off.
Stream<FoundStrap> scanStraps();
}

View file

@ -19,6 +19,7 @@ const recordingFileMagic = 'halkyon-train-recording';
/// s <time>
/// pause <time> auto|manual
/// resume <time> auto|manual
/// strap <time> connecting|connected|lost
/// end <time>
/// ```
///
@ -50,6 +51,7 @@ Recording? readRecording(String text) {
final heartRates = <HeartRate>[];
final steps = <DateTime>[];
final pauses = <PauseMark>[];
final straps = <StrapMark>[];
DateTime? endedAt;
for (final line in lines.skip(1)) {
final fields = line.split(' ');
@ -87,6 +89,10 @@ Recording? readRecording(String text) {
pauses.add(
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
);
case ['strap', _, final name]:
if (StrapState.values.asNameMap()[name] case final state?) {
straps.add(StrapMark(time: time, state: state));
}
case ['end', _]:
endedAt = time;
}
@ -98,6 +104,7 @@ Recording? readRecording(String text) {
heartRates: heartRates,
steps: steps,
pauses: pauses,
straps: straps,
endedAt: endedAt,
);
}

View file

@ -0,0 +1,45 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:equatable/equatable.dart';
/// The person's heart-rate strap on this device (ADR 0030, D27): where it is
/// and what it is called. Never synced, as each phone finds the strap anew.
final class const Strap({required final String address, final String? name})
with Equatable {
@override
List<Object?> get props => [address, name];
}
/// Remembers the [Strap] in a file of the person's folder; a file it can't
/// read is no strap.
class StrapStore(final File _file) {
Strap? read() {
if (!_file.existsSync()) {
return null;
}
try {
return switch (jsonDecode(_file.readAsStringSync())) {
{'address': final String address, 'name': final String? name} => Strap(
address: address,
name: name,
),
_ => null,
};
} on FormatException {
log('The strap not read', name: 'Recording');
return null;
}
}
void save(Strap strap) => _file.writeAsStringSync(
jsonEncode({'address': strap.address, 'name': strap.name}),
);
void forget() {
if (_file.existsSync()) {
_file.deleteSync();
}
}
}

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What the recording screen shows of a recording under way (ADR 0030, D21).
@ -27,6 +28,12 @@ final class const LiveRecording({
/// Whether it is paused now, and whether Train paused it by itself.
final bool paused = false,
final bool autoPaused = false,
/// The strap's state; null where it records without one (D27).
final StrapState? strap,
/// The heart's rate now, from a connected strap's last few seconds.
final int? bpm,
}) with Equatable {
@override
List<Object?> get props => [
@ -39,5 +46,7 @@ final class const LiveRecording({
waitingForGps,
paused,
autoPaused,
strap,
bpm,
];
}

View file

@ -29,6 +29,27 @@ final class const HeartRate({
List<Object?> get props => [time, bpm];
}
/// Where the heart-rate strap stands (D27), as the recorder sees it.
enum StrapState() {
/// Asked for, not answering yet: at the start, and out of reach.
connecting,
/// Sending.
connected,
/// Gone, the recorder asking for it again by itself.
lost,
}
/// The strap's state from [time] on.
final class const StrapMark({
required final DateTime time,
required final StrapState state,
}) with Equatable {
@override
List<Object?> get props => [time, state];
}
/// Paused, or going on again (D16), by the person or by Train itself.
final class const PauseMark({
required final DateTime time,
@ -57,6 +78,9 @@ final class const Recording({
final List<DateTime> steps = const [],
final List<PauseMark> pauses = const [],
/// The strap's states, where it was recorded with one (D27).
final List<StrapMark> straps = const [],
/// When the person stopped it; null for one cut off (D18).
final DateTime? endedAt,
}) with Equatable {
@ -81,6 +105,7 @@ final class const Recording({
heartRates,
steps,
pauses,
straps,
endedAt,
];
}

View file

@ -1,8 +1,13 @@
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/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/strap_sheet.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The activities a recording may be of on this version: rowing indoors
@ -19,8 +24,9 @@ bool autoPausesByDefault(Activity activity) =>
activity == Activity.running || activity == Activity.cycling;
/// The sheet a recording starts from (D21): the activities, the one last
/// recorded chosen, and auto-pause's switch; true once Start was pressed,
/// the start asked of [bloc].
/// recorded chosen, auto-pause's switch, and the heart-rate strap, chosen
/// there and switched off for one workout (D27); true once Start was
/// pressed, the start asked of [bloc].
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
await showAdaptiveSheet<bool>(
context,
@ -41,6 +47,9 @@ class _RecordSheetState() extends State<_RecordSheet> {
: Activity.running;
late var _autoPause = autoPausesByDefault(_activity);
/// Whether this workout records with the strap, where there is one.
var _useStrap = true;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -72,12 +81,43 @@ class _RecordSheetState() extends State<_RecordSheet> {
title: Text(l10n.recordAutoPause),
subtitle: Text(l10n.recordAutoPauseHint),
),
BlocBuilder<RecordingBloc, RecordingState>(
bloc: widget.bloc,
buildWhen: (before, now) => before.strap != now.strap,
builder: (context, state) {
final strap = state.strap;
return ListTile(
leading: const Icon(LucideIcons.heartPulse),
title: Text(l10n.recordHeartRate),
subtitle: Text(
strap == null
? l10n.recordNoStrap
: strap.name ?? l10n.strapUnnamed,
),
trailing: strap == null
? const Icon(LucideIcons.chevronRight)
: Switch(
value: _useStrap,
onChanged: (value) => setState(() => _useStrap = value),
),
onTap: () => showStrapSheet(
context,
widget.bloc,
PersonScope.of<TrainPerson>(context).recording!.platform,
),
);
},
),
],
),
footer: FilledButton(
onPressed: () {
widget.bloc.add(
RecordingEventStartRequested(_activity, autoPause: _autoPause),
RecordingEventStartRequested(
_activity,
autoPause: _autoPause,
useStrap: _useStrap,
),
);
Navigator.of(context).pop(true);
},

View file

@ -5,6 +5,7 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.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';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -88,15 +89,20 @@ class const _Recording({required final LiveRecording? live})
_ => null,
};
final pace = showsPace(live.activity);
// The strap lost says so where GPS's state shows (D27).
final strapLost = live.strap == StrapState.lost;
final status = strapLost ? l10n.recordingStrapLost : gps;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
if (gps != null)
if (status != null)
Text(
gps,
status,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
color: strapLost
? theme.colorScheme.tertiary
: theme.colorScheme.onSurfaceVariant,
),
),
const Spacer(),
@ -140,6 +146,13 @@ class const _Recording({required final LiveRecording? live})
),
],
),
if (live.strap != null) ...[
const SizedBox(height: 24),
_Figure(
value: live.bpm?.toString() ?? '–',
label: l10n.recordingHeartRate,
),
],
const Spacer(),
Row(
// Level with Stop, whose hint shows above it.

View file

@ -0,0 +1,93 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/strap_search_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A signal stronger than this is close by, as a strap worn or in the hand.
const _nearRssi = -65;
/// The sheet a heart-rate strap is chosen on (ADR 0030, D27): the straps
/// seen nearby, the nearest first, once nearby devices are allowed; choosing
/// one remembers it ([bloc]); Forget strap, where one is remembered.
Future<void> showStrapSheet(
BuildContext context,
RecordingBloc bloc,
RecorderPlatform platform,
) => showAdaptiveSheet<void>(
context,
builder: (context) => BlocProvider(
create: (context) => StrapSearchCubit(platform)..open(),
child: _StrapSheet(bloc: bloc),
),
);
class const _StrapSheet({required final RecordingBloc bloc})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final remembered = bloc.state.strap;
return SheetScaffold(
title: l10n.strapSheetTitle,
body: BlocBuilder<StrapSearchCubit, StrapSearchState>(
builder: (context, state) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: switch (state.phase) {
StrapSearchPhase.asking => const [
Center(child: AppLoadingIndicator()),
],
StrapSearchPhase.refused => [Text(l10n.strapRefused)],
StrapSearchPhase.bluetoothOff => [Text(l10n.strapBluetoothOff)],
StrapSearchPhase.searching => [
const LinearProgressIndicator(),
const SizedBox(height: 12),
Text(l10n.strapSearching, style: theme.textTheme.titleSmall),
Text(
l10n.strapSearchingHint,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
for (final strap in state.found)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.heartPulse),
title: Text(strap.name ?? l10n.strapUnnamed),
subtitle: Text(
strap.rssi > _nearRssi ? l10n.strapNear : l10n.strapFar,
),
trailing: remembered?.address == strap.address
? const Icon(LucideIcons.check)
: null,
onTap: () {
bloc.add(
RecordingEventStrapChosen(
Strap(address: strap.address, name: strap.name),
),
);
Navigator.of(context).pop();
},
),
],
},
),
),
footer: remembered == null
? null
: OutlinedButton(
onPressed: () {
bloc.add(const RecordingEventStrapForgotten());
Navigator.of(context).pop();
},
child: Text(l10n.strapForget),
),
);
}
}

View file

@ -15,13 +15,17 @@ final class const Split({
/// How long it is: the split's length, the last one as long as it went.
required final int metres,
required final Duration movingTime,
/// The heart's average rate over it, where a strap recorded it (D27).
final int? averageBpm,
}) with Equatable {
@override
List<Object?> get props => [metres, movingTime];
List<Object?> get props => [metres, movingTime, averageBpm];
}
/// A point of a chart over the distance: `metres` in, and its `value`.
typedef ChartPoint = ({double metres, double value});
/// A point of a chart: `x` metres in (or seconds of moving time, where a
/// chart runs over the time), and its `value`.
typedef ChartPoint = ({double x, double value});
/// What a workout's page shows of its series (D24).
final class const WorkoutAnalysis({
@ -42,6 +46,16 @@ final class const WorkoutAnalysis({
/// The altitude in metres over the distance; empty without altitudes.
required final List<ChartPoint> elevation,
/// The heart's average and highest rate while moving, where a strap
/// recorded it (D27).
final int? averageBpm,
final int? maxBpm,
/// The heart's rate over the distance where it has a track
/// ([heartRateByDistance]), else over the moving time.
final List<ChartPoint> heartRate = const [],
final bool heartRateByDistance = false,
});
/// How long a split is for [activity].
@ -50,22 +64,120 @@ int splitMetresOf(Activity activity) =>
/// [content] and its [series] read for its page.
WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
final points = _moving(_recordingOf(content, series));
final recording = _recordingOf(content, series);
final points = _moving(recording);
final byDistance = points.length > 1 && points.last.metres > 0;
final rates = [
for (final rate in _movingRates(recording))
(
bpm: rate.bpm,
x: byDistance
? _metresAt(points, rate.moving)
: rate.moving.inMilliseconds / 1000,
),
];
final bpms = [for (final rate in rates) rate.bpm];
return WorkoutAnalysis(
track: [
for (final piece in _pieces(points)) [for (final p in piece) p.fix],
],
splits: _splits(points, splitMetresOf(content.activity)),
splits: _splits(
points,
splitMetresOf(content.activity),
byDistance ? rates : const [],
),
elevationGainMetres: _gain(points),
chart: _chart(points, pace: content.activity != Activity.cycling),
elevation: _elevation(points),
averageBpm: bpms.isEmpty ? null : _mean(bpms),
maxBpm: bpms.isEmpty ? null : bpms.reduce(max),
heartRate: _binned(
rates,
byDistance
? _binMetres(points)
: max(10, (rates.lastOrNull?.x ?? 0) / 100),
),
heartRateByDistance: byDistance,
);
}
/// A heart rate, at `x` metres in or seconds of moving time.
typedef _Rate = ({int bpm, double x});
int _mean(List<int> values) =>
(values.reduce((a, b) => a + b) / values.length).round();
/// [recording]'s heart rates taken while moving, with the moving time each
/// was taken at.
List<({int bpm, Duration moving})> _movingRates(Recording recording) {
final pauses = recording.pauses;
final rates = <({int bpm, Duration moving})>[];
var next = 0;
var paused = false;
var pausedFor = Duration.zero;
DateTime? pausedAt;
for (final rate in recording.heartRates) {
while (next < pauses.length && !pauses[next].time.isAfter(rate.time)) {
final mark = pauses[next++];
if (mark.paused && !paused) {
pausedAt = mark.time;
} else if (!mark.paused && paused && pausedAt != null) {
pausedFor += mark.time.difference(pausedAt);
}
paused = mark.paused;
}
if (!paused) {
rates.add((
bpm: rate.bpm,
moving: rate.time.difference(recording.startedAt) - pausedFor,
));
}
}
return rates;
}
/// How far it had gone at [moving], between the points around it.
double _metresAt(List<_Point> points, Duration moving) {
if (moving <= points.first.moving) {
return points.first.metres;
}
for (var i = 1; i < points.length; i++) {
final (a, b) = (points[i - 1], points[i]);
if (b.moving >= moving) {
final span = (b.moving - a.moving).inMilliseconds;
final share = span == 0 ? 1.0 : (moving - a.moving).inMilliseconds / span;
return a.metres + share * (b.metres - a.metres);
}
}
return points.last.metres;
}
/// [rates] averaged over stretches of [bin], each at its stretch's end.
List<ChartPoint> _binned(List<_Rate> rates, double bin) {
final chart = <ChartPoint>[];
final inBin = <int>[];
var end = bin;
for (final rate in rates) {
while (rate.x > end) {
if (inBin.isNotEmpty) {
chart.add((x: end, value: _mean(inBin).toDouble()));
inBin.clear();
}
end += bin;
}
inBin.add(rate.bpm);
}
if (inBin.isNotEmpty) {
chart.add((x: rates.last.x, value: _mean(inBin).toDouble()));
}
return chart;
}
/// The recording [series] hold again, as it was saved.
Recording _recordingOf(WorkoutContent content, List<SeriesChunk> series) {
final fixes = <Fix>[];
final pauses = <PauseMark>[];
final heartRates = <HeartRate>[];
for (final chunk in series) {
final times = chunk.absoluteTimes;
final columns = chunk.columns;
@ -100,16 +212,22 @@ Recording _recordingOf(WorkoutContent content, List<SeriesChunk> series) {
);
}
case SeriesKind.heartRate:
break;
for (var i = 0; i < times.length; i++) {
if (columns['bpm']?[i] case final bpm?) {
heartRates.add(HeartRate(time: times[i], bpm: bpm));
}
}
}
}
fixes.sort((a, b) => a.time.compareTo(b.time));
pauses.sort((a, b) => a.time.compareTo(b.time));
heartRates.sort((a, b) => a.time.compareTo(b.time));
return Recording(
activity: content.activity,
startedAt: content.startedAt,
fixes: fixes,
pauses: pauses,
heartRates: heartRates,
endedAt: content.endedAt,
);
}
@ -186,7 +304,7 @@ Duration _movingAt(List<_Point> points, double metres) {
return points.last.moving;
}
List<Split> _splits(List<_Point> points, int length) {
List<Split> _splits(List<_Point> points, int length, List<_Rate> rates) {
if (points.length < 2) {
return const [];
}
@ -199,7 +317,18 @@ List<Split> _splits(List<_Point> points, int length) {
final moving = last ? points.last.moving : _movingAt(points, at);
final metres = (at - (end - length)).round();
if (metres > 0) {
splits.add(Split(metres: metres, movingTime: moving - before));
final from = end - length;
final bpms = [
for (final rate in rates)
if (rate.x >= from && (rate.x < at || last)) rate.bpm,
];
splits.add(
Split(
metres: metres,
movingTime: moving - before,
averageBpm: bpms.isEmpty ? null : _mean(bpms),
),
);
}
before = moving;
if (last) {
@ -248,7 +377,7 @@ List<ChartPoint> _chart(List<_Point> points, {required bool pace}) {
final metres = to - from;
if (seconds > 0 && metres > 0) {
chart.add((
metres: to,
x: to,
value: pace ? seconds / metres * 1000 : metres / seconds * 3.6,
));
}
@ -260,5 +389,5 @@ List<ChartPoint> _chart(List<_Point> points, {required bool pace}) {
List<ChartPoint> _elevation(List<_Point> points) => [
for (final p in points)
if (p.fix.altitudeCm case final cm?) (metres: p.metres, value: cm / 100),
if (p.fix.altitudeCm case final cm?) (x: p.metres, value: cm / 100),
];

Some files were not shown because too many files have changed in this diff Show more