feat(train): rowing on the WP 5000 over FTMS (ADR 0030, D28)

Rowing indoors is offered again, with the rower chosen on the Record
sheet: a sheet looks for Fitness Machine Service rowers, and the one
chosen is remembered in the person's folder. The recorder service
connects to it as to the strap (one NotifyLink for both), parses
FTMS's Rower Data in Kotlin (tested on the JVM on frames written from
the specification and D8's), counts its totals from its first frame,
pauses 5 s after the last stroke, and gets the connectedDevice type. A
row's screen shows its metres, split per 500 m, stroke rate and watts;
its page its average split, stroke rate and power, the highest power,
its strokes, splits per 500 m and charts of the split, stroke rate and
power. Not yet tried on the WP 5000.

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 16:20:37 +01:00
commit f3d08e4e69
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
29 changed files with 2266 additions and 514 deletions

View file

@ -94,11 +94,40 @@
"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.",
"strapBluetoothOff": "Bluetooth ist aus. Schalte es ein, um nach Geräten in der Nähe zu suchen.",
"recordingHeartRate": "Herzfrequenz",
"recordingStrapLost": "Brustgurt nicht verbunden",
"workoutAverageHeartRate": "mittlere Herzfrequenz",
"workoutMaxHeartRate": "höchste Herzfrequenz",
"workoutBpmValue": "{bpm} bpm",
"workoutHeartRateChart": "Herzfrequenz"
"workoutHeartRateChart": "Herzfrequenz",
"recordRower": "Rudergerät",
"recordNoRower": "Wähle dein Rudergerät",
"recordRowerRefused": "Train braucht Geräte in der Nähe, um von deinem Rudergerät aufzuzeichnen. Du kannst das in den Systemeinstellungen erlauben.",
"rowerSheetTitle": "Rudergerät",
"rowerSearching": "Suche Rudergeräte in der Nähe …",
"rowerSearchingHint": "Weck die Konsole: Die meisten erscheinen, sobald du ziehst oder eine Taste drückst. Eines, das eine andere App hält (Christopeit Connect, Kinomap), ist nicht zu finden: Schließ diese App zuerst.",
"rowerUnnamed": "Rudergerät ohne Namen",
"rowerForget": "Rudergerät vergessen",
"rowerRefused": "Train braucht Geräte in der Nähe, um dein Rudergerät zu finden. Du kannst das in den Systemeinstellungen erlauben.",
"recordingRowerConnecting": "Suche das Rudergerät",
"recordingRowerLost": "Rudergerät nicht verbunden",
"recordingSplit": "Zeit pro 500 m",
"recordingStrokeRate": "Schläge pro Minute",
"recordingWatts": "Watt",
"workoutSplitValue": "{split} /500 m",
"workoutAverageSplit": "mittlere Zeit pro 500 m",
"workoutAverageStrokeRate": "mittlere Schlagzahl",
"workoutStrokeRateValue": "{rate} /min",
"workoutAveragePower": "mittlere Leistung",
"workoutMaxPower": "höchste Leistung",
"workoutWattsValue": "{watts} W",
"workoutStrokes": "Schläge",
"workoutSplitMetres": "{from}–{to} m",
"workoutSplitChart": "Zeit pro 500 m",
"workoutStrokeRateChart": "Schlagzahl",
"workoutPowerChart": "Leistung",
"recordAutoPauseRowingHint": "Wenn du aufhörst zu rudern, weiter mit dem nächsten Schlag",
"recordingMetres": "Meter"
}

View file

@ -120,7 +120,7 @@
"@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.",
"strapBluetoothOff": "Bluetooth is off. Turn it on to look for devices nearby.",
"recordingHeartRate": "heart rate",
"@recordingHeartRate": {"description": "Under the heart rate, lower case."},
"recordingStrapLost": "Strap lost",
@ -131,5 +131,48 @@
"@workoutMaxHeartRate": {"description": "Lower case, under a figure."},
"workoutBpmValue": "{bpm} bpm",
"@workoutBpmValue": {"placeholders": {"bpm": {"type": "int"}}},
"workoutHeartRateChart": "Heart rate"
"workoutHeartRateChart": "Heart rate",
"recordRower": "Rowing machine",
"recordNoRower": "Choose your rowing machine",
"recordRowerRefused": "Train needs nearby devices to record from your rowing machine. You can allow it in the system's settings.",
"rowerSheetTitle": "Rowing machine",
"rowerSearching": "Looking for rowing machines nearby…",
"rowerSearchingHint": "Wake the console: most show once you pull or press a button. One another app holds (Christopeit Connect, Kinomap) can't be found: close that app first.",
"rowerUnnamed": "Unnamed rowing machine",
"rowerForget": "Forget rowing machine",
"rowerRefused": "Train needs nearby devices to find your rowing machine. You can allow it in the system's settings.",
"recordingRowerConnecting": "Looking for the rowing machine",
"@recordingRowerConnecting": {"description": "While recording a row: the rower isn't connected yet. Another app may hold it."},
"recordingRowerLost": "Rowing machine lost",
"@recordingRowerLost": {"description": "While recording a row: the rower is out of reach or off; Train keeps asking for it."},
"recordingSplit": "split",
"@recordingSplit": {"description": "Lower case, under a figure: how long 500 m take now."},
"recordingStrokeRate": "strokes a minute",
"@recordingStrokeRate": {"description": "Lower case, under a figure."},
"recordingWatts": "watts",
"@recordingWatts": {"description": "Lower case, under a figure."},
"workoutSplitValue": "{split} /500 m",
"@workoutSplitValue": {"description": "Minutes and seconds 500 m take.", "placeholders": {"split": {"type": "String", "example": "2:04"}}},
"workoutAverageSplit": "average split",
"@workoutAverageSplit": {"description": "Lower case, under a figure."},
"workoutAverageStrokeRate": "average stroke rate",
"@workoutAverageStrokeRate": {"description": "Lower case, under a figure."},
"workoutStrokeRateValue": "{rate} /min",
"@workoutStrokeRateValue": {"description": "Strokes a minute.", "placeholders": {"rate": {"type": "String", "example": "24"}}},
"workoutAveragePower": "average power",
"@workoutAveragePower": {"description": "Lower case, under a figure."},
"workoutMaxPower": "highest power",
"@workoutMaxPower": {"description": "Lower case, under a figure."},
"workoutWattsValue": "{watts} W",
"@workoutWattsValue": {"placeholders": {"watts": {"type": "int"}}},
"workoutStrokes": "strokes",
"@workoutStrokes": {"description": "Lower case, under a figure: how many strokes."},
"workoutSplitMetres": "{from}–{to} m",
"@workoutSplitMetres": {"description": "A rowing split of 500 m: where it starts and ends.", "placeholders": {"from": {"type": "int"}, "to": {"type": "int"}}},
"workoutSplitChart": "Split",
"workoutStrokeRateChart": "Stroke rate",
"workoutPowerChart": "Power",
"recordAutoPauseRowingHint": "When you stop rowing, going on with the next stroke",
"recordingMetres": "metres"
}

View file

@ -619,7 +619,7 @@ abstract class AppLocalizations {
/// No description provided for @strapBluetoothOff.
///
/// In en, this message translates to:
/// **'Bluetooth is off. Turn it on to find your strap.'**
/// **'Bluetooth is off. Turn it on to look for devices nearby.'**
String get strapBluetoothOff;
/// Under the heart rate, lower case.
@ -657,6 +657,174 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Heart rate'**
String get workoutHeartRateChart;
/// No description provided for @recordRower.
///
/// In en, this message translates to:
/// **'Rowing machine'**
String get recordRower;
/// No description provided for @recordNoRower.
///
/// In en, this message translates to:
/// **'Choose your rowing machine'**
String get recordNoRower;
/// No description provided for @recordRowerRefused.
///
/// In en, this message translates to:
/// **'Train needs nearby devices to record from your rowing machine. You can allow it in the system\'s settings.'**
String get recordRowerRefused;
/// No description provided for @rowerSheetTitle.
///
/// In en, this message translates to:
/// **'Rowing machine'**
String get rowerSheetTitle;
/// No description provided for @rowerSearching.
///
/// In en, this message translates to:
/// **'Looking for rowing machines nearby…'**
String get rowerSearching;
/// No description provided for @rowerSearchingHint.
///
/// In en, this message translates to:
/// **'Wake the console: most show once you pull or press a button. One another app holds (Christopeit Connect, Kinomap) can\'t be found: close that app first.'**
String get rowerSearchingHint;
/// No description provided for @rowerUnnamed.
///
/// In en, this message translates to:
/// **'Unnamed rowing machine'**
String get rowerUnnamed;
/// No description provided for @rowerForget.
///
/// In en, this message translates to:
/// **'Forget rowing machine'**
String get rowerForget;
/// No description provided for @rowerRefused.
///
/// In en, this message translates to:
/// **'Train needs nearby devices to find your rowing machine. You can allow it in the system\'s settings.'**
String get rowerRefused;
/// While recording a row: the rower isn't connected yet. Another app may hold it.
///
/// In en, this message translates to:
/// **'Looking for the rowing machine'**
String get recordingRowerConnecting;
/// While recording a row: the rower is out of reach or off; Train keeps asking for it.
///
/// In en, this message translates to:
/// **'Rowing machine lost'**
String get recordingRowerLost;
/// Lower case, under a figure: how long 500 m take now.
///
/// In en, this message translates to:
/// **'split'**
String get recordingSplit;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'strokes a minute'**
String get recordingStrokeRate;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'watts'**
String get recordingWatts;
/// Minutes and seconds 500 m take.
///
/// In en, this message translates to:
/// **'{split} /500 m'**
String workoutSplitValue(String split);
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'average split'**
String get workoutAverageSplit;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'average stroke rate'**
String get workoutAverageStrokeRate;
/// Strokes a minute.
///
/// In en, this message translates to:
/// **'{rate} /min'**
String workoutStrokeRateValue(String rate);
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'average power'**
String get workoutAveragePower;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'highest power'**
String get workoutMaxPower;
/// No description provided for @workoutWattsValue.
///
/// In en, this message translates to:
/// **'{watts} W'**
String workoutWattsValue(int watts);
/// Lower case, under a figure: how many strokes.
///
/// In en, this message translates to:
/// **'strokes'**
String get workoutStrokes;
/// A rowing split of 500 m: where it starts and ends.
///
/// In en, this message translates to:
/// **'{from}–{to} m'**
String workoutSplitMetres(int from, int to);
/// No description provided for @workoutSplitChart.
///
/// In en, this message translates to:
/// **'Split'**
String get workoutSplitChart;
/// No description provided for @workoutStrokeRateChart.
///
/// In en, this message translates to:
/// **'Stroke rate'**
String get workoutStrokeRateChart;
/// No description provided for @workoutPowerChart.
///
/// In en, this message translates to:
/// **'Power'**
String get workoutPowerChart;
/// No description provided for @recordAutoPauseRowingHint.
///
/// In en, this message translates to:
/// **'When you stop rowing, going on with the next stroke'**
String get recordAutoPauseRowingHint;
/// No description provided for @recordingMetres.
///
/// In en, this message translates to:
/// **'metres'**
String get recordingMetres;
}
class _AppLocalizationsDelegate

View file

@ -333,7 +333,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get strapBluetoothOff =>
'Bluetooth ist aus. Schalte es ein, um deinen Brustgurt zu finden.';
'Bluetooth ist aus. Schalte es ein, um nach Geräten in der Nähe zu suchen.';
@override
String get recordingHeartRate => 'Herzfrequenz';
@ -354,4 +354,100 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get workoutHeartRateChart => 'Herzfrequenz';
@override
String get recordRower => 'Rudergerät';
@override
String get recordNoRower => 'Wähle dein Rudergerät';
@override
String get recordRowerRefused =>
'Train braucht Geräte in der Nähe, um von deinem Rudergerät aufzuzeichnen. Du kannst das in den Systemeinstellungen erlauben.';
@override
String get rowerSheetTitle => 'Rudergerät';
@override
String get rowerSearching => 'Suche Rudergeräte in der Nähe …';
@override
String get rowerSearchingHint =>
'Weck die Konsole: Die meisten erscheinen, sobald du ziehst oder eine Taste drückst. Eines, das eine andere App hält (Christopeit Connect, Kinomap), ist nicht zu finden: Schließ diese App zuerst.';
@override
String get rowerUnnamed => 'Rudergerät ohne Namen';
@override
String get rowerForget => 'Rudergerät vergessen';
@override
String get rowerRefused =>
'Train braucht Geräte in der Nähe, um dein Rudergerät zu finden. Du kannst das in den Systemeinstellungen erlauben.';
@override
String get recordingRowerConnecting => 'Suche das Rudergerät';
@override
String get recordingRowerLost => 'Rudergerät nicht verbunden';
@override
String get recordingSplit => 'Zeit pro 500 m';
@override
String get recordingStrokeRate => 'Schläge pro Minute';
@override
String get recordingWatts => 'Watt';
@override
String workoutSplitValue(String split) {
return '$split /500 m';
}
@override
String get workoutAverageSplit => 'mittlere Zeit pro 500 m';
@override
String get workoutAverageStrokeRate => 'mittlere Schlagzahl';
@override
String workoutStrokeRateValue(String rate) {
return '$rate /min';
}
@override
String get workoutAveragePower => 'mittlere Leistung';
@override
String get workoutMaxPower => 'höchste Leistung';
@override
String workoutWattsValue(int watts) {
return '$watts W';
}
@override
String get workoutStrokes => 'Schläge';
@override
String workoutSplitMetres(int from, int to) {
return '$from–$to m';
}
@override
String get workoutSplitChart => 'Zeit pro 500 m';
@override
String get workoutStrokeRateChart => 'Schlagzahl';
@override
String get workoutPowerChart => 'Leistung';
@override
String get recordAutoPauseRowingHint =>
'Wenn du aufhörst zu rudern, weiter mit dem nächsten Schlag';
@override
String get recordingMetres => 'Meter';
}

View file

@ -330,7 +330,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get strapBluetoothOff =>
'Bluetooth is off. Turn it on to find your strap.';
'Bluetooth is off. Turn it on to look for devices nearby.';
@override
String get recordingHeartRate => 'heart rate';
@ -351,6 +351,102 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workoutHeartRateChart => 'Heart rate';
@override
String get recordRower => 'Rowing machine';
@override
String get recordNoRower => 'Choose your rowing machine';
@override
String get recordRowerRefused =>
'Train needs nearby devices to record from your rowing machine. You can allow it in the system\'s settings.';
@override
String get rowerSheetTitle => 'Rowing machine';
@override
String get rowerSearching => 'Looking for rowing machines nearby…';
@override
String get rowerSearchingHint =>
'Wake the console: most show once you pull or press a button. One another app holds (Christopeit Connect, Kinomap) can\'t be found: close that app first.';
@override
String get rowerUnnamed => 'Unnamed rowing machine';
@override
String get rowerForget => 'Forget rowing machine';
@override
String get rowerRefused =>
'Train needs nearby devices to find your rowing machine. You can allow it in the system\'s settings.';
@override
String get recordingRowerConnecting => 'Looking for the rowing machine';
@override
String get recordingRowerLost => 'Rowing machine lost';
@override
String get recordingSplit => 'split';
@override
String get recordingStrokeRate => 'strokes a minute';
@override
String get recordingWatts => 'watts';
@override
String workoutSplitValue(String split) {
return '$split /500 m';
}
@override
String get workoutAverageSplit => 'average split';
@override
String get workoutAverageStrokeRate => 'average stroke rate';
@override
String workoutStrokeRateValue(String rate) {
return '$rate /min';
}
@override
String get workoutAveragePower => 'average power';
@override
String get workoutMaxPower => 'highest power';
@override
String workoutWattsValue(int watts) {
return '$watts W';
}
@override
String get workoutStrokes => 'strokes';
@override
String workoutSplitMetres(int from, int to) {
return '$from–$to m';
}
@override
String get workoutSplitChart => 'Split';
@override
String get workoutStrokeRateChart => 'Stroke rate';
@override
String get workoutPowerChart => 'Power';
@override
String get recordAutoPauseRowingHint =>
'When you stop rowing, going on with the next stroke';
@override
String get recordingMetres => 'metres';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

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

View file

@ -11,8 +11,8 @@ import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
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/device_store.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';
@ -22,12 +22,14 @@ 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, D27): its recorder,
/// and the person's recording file and strap in their folder.
/// Where a device records workouts (ADR 0030, D13, D18, D27, D28): its
/// recorder, and the person's recording file, strap and rower in their
/// folder.
typedef RecordingDevice = ({
RecorderPlatform platform,
File file,
StrapStore straps,
DeviceStore straps,
DeviceStore rowers,
});
/// A signed-in person's dependencies: who they are and their workouts, on
@ -298,7 +300,8 @@ Future<TrainPerson> _openPersonData(
? (
platform: AndroidRecorderPlatform(),
file: File(p.join(folder.path, 'recording.txt')),
straps: StrapStore(File(p.join(folder.path, 'strap.json'))),
straps: DeviceStore(File(p.join(folder.path, 'strap.json'))),
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
)
: null,
onDispose: () async {

View file

@ -0,0 +1,97 @@
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 straps or rowers nearby (ADR 0030, D27, D28), once nearby
/// devices are allowed (asked first, as D19 asks when a thing is first
/// needed), until it is closed.
class DeviceSearchCubit(
final RecorderPlatform _platform,
/// What it looks for.
final DeviceKind kind,
) extends Cubit<DeviceSearchState> {
this : super(const DeviceSearchState());
StreamSubscription<FoundDevice>? _scan;
Future<void> open() async {
final granted = await _platform.ask({RecorderPermission.bluetooth});
if (isClosed) {
return;
}
if (!granted.contains(RecorderPermission.bluetooth)) {
emit(const DeviceSearchState(phase: DeviceSearchPhase.refused));
return;
}
emit(const DeviceSearchState(phase: DeviceSearchPhase.searching));
_scan = _platform
.scan(kind)
.listen(
_seen,
onError: (Object error) {
if (error is BluetoothOff && !isClosed) {
emit(
const DeviceSearchState(phase: DeviceSearchPhase.bluetoothOff),
);
}
},
);
}
void _seen(FoundDevice device) {
final before = [
for (final found in state.found)
if (found.address == device.address) found,
].firstOrNull;
final seen = FoundDevice(
address: device.address,
// Some send their name in one advertisement and not the next.
name: device.name ?? before?.name,
rssi: device.rssi,
);
emit(
DeviceSearchState(
phase: state.phase,
found: [
for (final found in state.found)
if (found.address != device.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 DeviceSearchPhase() {
/// Asking for nearby devices.
asking,
/// Looking: what it found so far.
searching,
/// Nearby devices refused.
refused,
/// Bluetooth off.
bluetoothOff,
}
final class const DeviceSearchState({
final DeviceSearchPhase phase = DeviceSearchPhase.asking,
/// The devices seen, the nearest first.
final List<FoundDevice> found = const [],
}) with Equatable {
@override
List<Object?> get props => [phase, found];
}

View file

@ -5,11 +5,11 @@ 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/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';
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';
@ -21,7 +21,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// 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).
/// it is switched off for it (D27), and their rower, which a row needs
/// (D28).
///
/// The recorder decides the pauses (D16); this works out what the screen and
/// the notification show from the same lines the file holds.
@ -32,8 +33,9 @@ class RecordingBloc(
/// The person's recording file, in their folder.
final File _file,
/// The person's strap on this device, in their folder.
final StrapStore _straps, {
/// The person's strap and rower on this device, in their folder.
final DeviceStore _straps,
final DeviceStore _rowers, {
/// What the notification shows of [LiveRecording], in the app's language.
required final RecorderNotice Function(LiveRecording live) notice,
@ -49,13 +51,13 @@ 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<RecordingEventDeviceChosen>((event, emit) {
_storeOf(event.kind).save(event.device);
emit(state._withDevice(event.kind, event.device));
});
on<RecordingEventStrapForgotten>((event, emit) {
_straps.forget();
emit(state._withStrap(null));
on<RecordingEventDeviceForgotten>((event, emit) {
_storeOf(event.kind).forget();
emit(state._withDevice(event.kind, null));
});
on<RecordingEventLineWritten>((event, emit) {
_lines.writeln(event.line);
@ -82,12 +84,18 @@ class RecordingBloc(
/// What the notification shows now, so it is told only of changes.
(String, String, bool)? _shown;
DeviceStore _storeOf(DeviceKind kind) => switch (kind) {
DeviceKind.strap => _straps,
DeviceKind.rower => _rowers,
};
Future<void> _onOpened(
RecordingEventOpened event,
Emitter<RecordingState> emit,
) async {
final lastActivity = await _lastActivity();
final strap = _straps.read();
final rower = _rowers.read();
if (await _platform.isRecording()) {
_follow();
emit(
@ -95,6 +103,7 @@ class RecordingBloc(
phase: RecordingPhase.recording,
lastActivity: lastActivity,
strap: strap,
rower: rower,
),
);
return;
@ -110,6 +119,7 @@ class RecordingBloc(
lastActivity: lastActivity,
recording: kept,
strap: strap,
rower: rower,
),
);
}
@ -119,9 +129,21 @@ class RecordingBloc(
Emitter<RecordingState> emit,
) async {
// Asked anew: a refusal before says nothing of this one.
emit(state.copyWith(locationRefused: false));
emit(
state.copyWith(
locationRefused: false,
bluetoothRefused: false,
rowerMissing: false,
),
);
final activity = event.activity;
final gps = usesGps(activity);
final rows = activity == Activity.indoorRowing;
final rower = state.rower;
if (rows && rower == null) {
emit(state.copyWith(rowerMissing: true));
return;
}
final autoPause = !event.autoPause
? null
: switch (activity) {
@ -129,9 +151,11 @@ class RecordingBloc(
Activity.running ||
Activity.walking ||
Activity.hiking => AutoPauseBy.steps,
Activity.indoorRowing => null,
Activity.indoorRowing => AutoPauseBy.strokes,
};
final granted = await _platform.ask({
// The rower's connection, which also gives Android its service's type.
if (rows) RecorderPermission.bluetooth,
if (gps) RecorderPermission.location,
RecorderPermission.notifications,
if (autoPause == AutoPauseBy.steps) RecorderPermission.activity,
@ -140,6 +164,10 @@ class RecordingBloc(
emit(state.copyWith(locationRefused: true));
return;
}
if (rows && !granted.contains(RecorderPermission.bluetooth)) {
emit(state.copyWith(bluetoothRefused: true));
return;
}
final now = clock.now().toUtc();
final notice = _notice(
liveOf(Recording(activity: activity, startedAt: now), now),
@ -150,6 +178,7 @@ class RecordingBloc(
autoPause: autoPause,
notice: notice,
strap: event.useStrap ? state.strap?.address : null,
rower: rows ? rower?.address : null,
);
_shown = _keyOf(notice);
_follow();
@ -159,6 +188,7 @@ class RecordingBloc(
lastActivity: activity,
live: liveOf(Recording(activity: activity, startedAt: now), now),
strap: state.strap,
rower: state.rower,
),
);
}
@ -176,6 +206,7 @@ class RecordingBloc(
lastActivity: state.lastActivity,
recording: recording,
strap: state.strap,
rower: state.rower,
),
);
}
@ -203,6 +234,7 @@ class RecordingBloc(
phase: RecordingPhase.idle,
lastActivity: recording.activity,
strap: state.strap,
rower: state.rower,
),
);
}
@ -217,6 +249,7 @@ class RecordingBloc(
phase: RecordingPhase.idle,
lastActivity: state.lastActivity,
strap: state.strap,
rower: state.rower,
),
);
}
@ -337,13 +370,23 @@ 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,
/// Whether nearby devices were refused as a row was to start (D28).
final bool bluetoothRefused = false,
/// Whether a row was to start without a rower chosen.
final bool rowerMissing = false,
/// The person's strap and rower on this device, if they chose them (D27,
/// D28).
final KnownDevice? strap,
final KnownDevice? rower,
}) with Equatable {
RecordingState copyWith({
LiveRecording? live,
bool? locationRefused,
bool? saveFailed,
bool? bluetoothRefused,
bool? rowerMissing,
}) => RecordingState(
phase: phase,
lastActivity: lastActivity,
@ -351,18 +394,25 @@ final class const RecordingState({
recording: recording,
locationRefused: locationRefused ?? this.locationRefused,
saveFailed: saveFailed ?? this.saveFailed,
bluetoothRefused: bluetoothRefused ?? this.bluetoothRefused,
rowerMissing: rowerMissing ?? this.rowerMissing,
strap: strap,
rower: rower,
);
RecordingState _withStrap(Strap? strap) => RecordingState(
phase: phase,
lastActivity: lastActivity,
live: live,
recording: recording,
locationRefused: locationRefused,
saveFailed: saveFailed,
strap: strap,
);
RecordingState _withDevice(DeviceKind kind, KnownDevice? device) =>
RecordingState(
phase: phase,
lastActivity: lastActivity,
live: live,
recording: recording,
locationRefused: locationRefused,
saveFailed: saveFailed,
bluetoothRefused: bluetoothRefused,
rowerMissing: rowerMissing,
strap: kind == DeviceKind.strap ? device : strap,
rower: kind == DeviceKind.rower ? device : rower,
);
@override
List<Object?> get props => [
@ -372,7 +422,10 @@ final class const RecordingState({
recording,
locationRefused,
saveFailed,
bluetoothRefused,
rowerMissing,
strap,
rower,
];
}
@ -390,12 +443,15 @@ final class const RecordingEventStartRequested(
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 chose [device] as their [kind] on this device.
final class const RecordingEventDeviceChosen(
final DeviceKind kind,
final KnownDevice device,
) extends RecordingEvent;
/// The person forgot their strap on this device.
final class const RecordingEventStrapForgotten() extends RecordingEvent;
/// The person forgot their [kind] on this device.
final class const RecordingEventDeviceForgotten(final DeviceKind kind)
extends RecordingEvent;
final class const RecordingEventPauseRequested() extends RecordingEvent;

View file

@ -1,89 +0,0 @@
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,12 +7,15 @@ 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, and the heart-rate strap found and read there
/// (D26).
/// file appended to there, and the heart-rate strap and the rower found and
/// read there (D26 to D28).
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');
static const _scans = {
DeviceKind.strap: EventChannel('app.halkyon.train/recorder/straps'),
DeviceKind.rower: EventChannel('app.halkyon.train/recorder/rowers'),
};
@override
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted) async {
@ -32,12 +35,14 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
required AutoPauseBy? autoPause,
required RecorderNotice notice,
String? strap,
String? rower,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
'autoPause': autoPause?.name,
'notice': noticeJson(notice),
'strap': strap,
'rower': rower,
});
@override
@ -60,10 +65,10 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
@override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();
/// Each strap seen as `{address, name, rssi}`; the error `off` where
/// Each device seen as `{address, name, rssi}`; the error `off` where
/// Bluetooth is.
@override
Stream<FoundStrap> scanStraps() => _straps
Stream<FoundDevice> scan(DeviceKind kind) => _scans[kind]!
.receiveBroadcastStream()
.handleError(
(Object error) => throw BluetoothOff(),
@ -72,7 +77,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
.map(
(event) => switch (event) {
{'address': final String address, 'rssi': final int rssi} =>
FoundStrap(
FoundDevice(
address: address,
name: switch (event['name']) {
final String name when name.isNotEmpty => name,
@ -80,7 +85,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
},
rssi: rssi,
),
_ => throw FormatException('A strap not read: $event'),
_ => throw FormatException('A device not read: $event'),
},
);
}

View file

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

View file

@ -6,10 +6,33 @@ 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.
/// How old a heart rate, a stroke rate or watts may be and still show: a
/// strap and a rower send about once a second.
const freshHeartRate = Duration(seconds: 5);
/// The split per 500 m over the rower's last [window] up to [now], from its
/// metres; null without metres enough.
Duration? _splitOf(
Recording recording,
DateTime now, {
Duration window = const Duration(seconds: 30),
}) {
final from = now.subtract(window);
final recent = [
for (final sample in recording.rowing)
if (!sample.time.isBefore(from) && sample.metres != null) sample,
];
if (recent.length < 2) {
return null;
}
final metres = recent.last.metres! - recent.first.metres!;
final time = recent.last.time.difference(recent.first.time);
if (metres < 10 || time.inSeconds < 10) {
return null;
}
return Duration(milliseconds: (time.inMilliseconds * 500 / metres).round());
}
/// 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) {
@ -27,10 +50,14 @@ LiveRecording liveOf(Recording recording, DateTime now) {
final lastFix = recording.fixes.lastOrNull;
final strap = recording.straps.lastOrNull?.state;
final rate = recording.heartRates.lastOrNull;
final rows = recording.rowing.isNotEmpty || recording.rowers.isNotEmpty;
final lastRowing = recording.rowing.lastOrNull;
final rowingFresh =
lastRowing != null && now.difference(lastRowing.time) <= freshHeartRate;
return LiveRecording(
activity: recording.activity,
movingTime: movingTimeOf(recording, now),
distanceMetres: gps ? distanceOf(recording) ?? 0 : null,
distanceMetres: gps || rows ? distanceOf(recording) ?? 0 : null,
pace: pace,
kmh: kmh,
accuracyMetres: switch (lastFix) {
@ -43,10 +70,14 @@ LiveRecording liveOf(Recording recording, DateTime now) {
autoPaused: paused && last!.auto,
strap: strap,
bpm:
strap == StrapState.connected &&
strap == LinkState.connected &&
rate != null &&
now.difference(rate.time) <= freshHeartRate
? rate.bpm
: null,
rower: recording.rowers.lastOrNull?.state,
split: paused ? null : _splitOf(recording, now),
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
watts: rowingFresh && !paused ? lastRowing.watts : null,
);
}

View file

@ -26,12 +26,40 @@ const maxAccuracyCm = 2000;
);
}
/// The track's length in metres, null without fixes: from fix to fix while
/// it moves, the imprecise ones left out, starting afresh after each pause.
int? distanceOf(Recording recording) => switch (_metres(recording)) {
final metres? => metres.round(),
null => null,
};
/// How far it went in metres: on the rower, the metres it counted while
/// moving (D28); else the track's length, null without fixes, from fix to fix
/// while it moves, the imprecise ones left out, starting afresh after each
/// pause.
int? distanceOf(Recording recording) => recording.rowing.isNotEmpty
? rowedMetresOf(recording)
: switch (_metres(recording)) {
final metres? => metres.round(),
null => null,
};
/// The metres the rower counted while not paused: each frame's growth over
/// the one before, those taken while paused left out.
int rowedMetresOf(Recording recording) {
final pauses = recording.pauses;
var next = 0;
var paused = false;
int? before;
var metres = 0;
for (final sample in recording.rowing) {
while (next < pauses.length && !pauses[next].time.isAfter(sample.time)) {
paused = pauses[next++].paused;
}
final now = sample.metres;
if (now == null) {
continue;
}
if (before != null && !paused && now > before) {
metres += now - before;
}
before = now;
}
return metres;
}
double? _metres(Recording recording) {
if (recording.fixes.isEmpty) {
@ -127,6 +155,7 @@ List<SeriesChunk> _series(Recording recording) {
final fixes = recording.fixes;
final rates = recording.heartRates;
final pauses = recording.pauses;
final rowing = recording.rowing;
return [
...chunksOf(
SeriesKind.position,
@ -147,6 +176,23 @@ List<SeriesChunk> _series(Recording recording) {
'bpm': [for (final rate in rates) rate.bpm],
},
),
...chunksOf(
SeriesKind.rowing,
start: start,
times: times(rowing.map((sample) => sample.time)),
columns: {
'rate2': [
for (final sample in rowing)
if (sample.strokeRate case final rate?)
(rate * 2).round()
else
null,
],
'strokes': [for (final sample in rowing) sample.strokes],
'metres': [for (final sample in rowing) sample.metres],
'watts': [for (final sample in rowing) sample.watts],
},
),
...chunksOf(
SeriesKind.pauses,
start: start,

View file

@ -12,14 +12,14 @@ 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).
/// Nearby devices, for finding and connecting to a strap or a rower (D27,
/// D28; 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({
/// A device seen nearby (D27, D28): its address, its name where it sends
/// one, and how strong its signal is, in dBm.
final class const FoundDevice({
required final String address,
final String? name,
required final int rssi,
@ -38,6 +38,18 @@ enum AutoPauseBy() {
/// By bike: by GPS speed.
bike,
/// On the rower: by its strokes (D28).
strokes,
}
/// What a recording connects to (D27, D28).
enum DeviceKind() {
/// A heart-rate strap (the Heart Rate Service).
strap,
/// A rower (the Fitness Machine Service).
rower,
}
/// What the recording's notification shows (D21), in the app's language.
@ -78,14 +90,15 @@ 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), with the heart-rate strap at [strap]
/// (null: none).
/// by [autoPause] (null: never), with the heart-rate strap at [strap] and
/// the rower at [rower] (null: none).
Future<void> start({
required String path,
required Activity activity,
required AutoPauseBy? autoPause,
required RecorderNotice notice,
String? strap,
String? rower,
});
/// Pauses, or goes on, by hand.
@ -105,7 +118,7 @@ abstract interface class RecorderPlatform() {
/// written, while a recording runs.
Stream<String> lines();
/// The heart-rate straps nearby, each as it is seen, until the stream is
/// The devices of [kind] nearby, each as it is seen, until the stream is
/// cancelled; [BluetoothOff] where it is off.
Stream<FoundStrap> scanStraps();
Stream<FoundDevice> scan(DeviceKind kind);
}

View file

@ -20,6 +20,8 @@ const recordingFileMagic = 'halkyon-train-recording';
/// pause <time> auto|manual
/// resume <time> auto|manual
/// strap <time> connecting|connected|lost
/// r <time> <stroke rate or -> <strokes or -> <metres or -> <watts or ->
/// rower <time> connecting|connected|lost
/// end <time>
/// ```
///
@ -51,7 +53,9 @@ Recording? readRecording(String text) {
final heartRates = <HeartRate>[];
final steps = <DateTime>[];
final pauses = <PauseMark>[];
final straps = <StrapMark>[];
final straps = <LinkMark>[];
final rowing = <RowingSample>[];
final rowers = <LinkMark>[];
DateTime? endedAt;
for (final line in lines.skip(1)) {
final fields = line.split(' ');
@ -89,10 +93,23 @@ 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 [final link, _, final name]
when link == 'strap' || link == 'rower':
if (LinkState.values.asNameMap()[name] case final state?) {
(link == 'strap' ? straps : rowers).add(
LinkMark(time: time, state: state),
);
}
case ['r', _, final rate, final strokes, final metres, final watts]:
rowing.add(
RowingSample(
time: time,
strokeRate: double.tryParse(rate),
strokes: int.tryParse(strokes),
metres: int.tryParse(metres),
watts: int.tryParse(watts),
),
);
case ['end', _]:
endedAt = time;
}
@ -105,6 +122,8 @@ Recording? readRecording(String text) {
steps: steps,
pauses: pauses,
straps: straps,
rowing: rowing,
rowers: rowers,
endedAt: endedAt,
);
}

View file

@ -1,45 +0,0 @@
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

@ -9,7 +9,7 @@ final class const LiveRecording({
/// How long it moved so far, its pauses left out (D16).
required final Duration movingTime,
/// How far it went so far; null for an activity without GPS.
/// How far it went so far; null for an activity without GPS or rower.
required final int? distanceMetres,
/// How long a kilometre takes at the pace of the last half minute; null
@ -30,10 +30,19 @@ final class const LiveRecording({
final bool autoPaused = false,
/// The strap's state; null where it records without one (D27).
final StrapState? strap,
final LinkState? strap,
/// The heart's rate now, from a connected strap's last few seconds.
final int? bpm,
/// The rower's state; null where it records without one (D28).
final LinkState? rower,
/// On the rower: how long 500 m take at the pace of the last half minute,
/// and the stroke rate and watts of its last few seconds.
final Duration? split,
final double? strokeRate,
final int? watts,
}) with Equatable {
@override
List<Object?> get props => [
@ -48,5 +57,9 @@ final class const LiveRecording({
autoPaused,
strap,
bpm,
rower,
split,
strokeRate,
watts,
];
}

View file

@ -29,8 +29,23 @@ final class const HeartRate({
List<Object?> get props => [time, bpm];
}
/// Where the heart-rate strap stands (D27), as the recorder sees it.
enum StrapState() {
/// A frame of the rower's (D28): its stroke rate, and its strokes and metres
/// since the recording's first frame; each null where the frame lacks it.
final class const RowingSample({
required final DateTime time,
/// Strokes a minute, in halves.
final double? strokeRate,
final int? strokes,
final int? metres,
final int? watts,
}) with Equatable {
@override
List<Object?> get props => [time, strokeRate, strokes, metres, watts];
}
/// Where the strap or the rower stands (D27, D28), as the recorder sees it.
enum LinkState() {
/// Asked for, not answering yet: at the start, and out of reach.
connecting,
@ -41,10 +56,10 @@ enum StrapState() {
lost,
}
/// The strap's state from [time] on.
final class const StrapMark({
/// The strap's or the rower's state from [time] on.
final class const LinkMark({
required final DateTime time,
required final StrapState state,
required final LinkState state,
}) with Equatable {
@override
List<Object?> get props => [time, state];
@ -79,7 +94,11 @@ final class const Recording({
final List<PauseMark> pauses = const [],
/// The strap's states, where it was recorded with one (D27).
final List<StrapMark> straps = const [],
final List<LinkMark> straps = const [],
/// The rower's frames and states (D28).
final List<RowingSample> rowing = const [],
final List<LinkMark> rowers = const [],
/// When the person stopped it; null for one cut off (D18).
final DateTime? endedAt,
@ -95,6 +114,7 @@ final class const Recording({
for (final rate in heartRates) rate.time,
...steps,
for (final mark in pauses) mark.time,
for (final sample in rowing) sample.time,
].reduce((a, b) => b.isAfter(a) ? b : a);
@override
@ -106,6 +126,8 @@ final class const Recording({
steps,
pauses,
straps,
rowing,
rowers,
endedAt,
];
}

View file

@ -0,0 +1,133 @@
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/device_search_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.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 or a rower is chosen on (ADR 0030, D27,
/// D28): those of [kind] seen nearby, the nearest first, once nearby devices
/// are allowed; choosing one remembers it ([bloc]); Forget, where one is
/// remembered.
Future<void> showDeviceSheet(
BuildContext context,
RecordingBloc bloc,
RecorderPlatform platform,
DeviceKind kind,
) => showAdaptiveSheet<void>(
context,
builder: (context) => BlocProvider(
create: (context) => DeviceSearchCubit(platform, kind)..open(),
child: _DeviceSheet(bloc: bloc, kind: kind),
),
);
/// The texts of a device sheet for each kind.
typedef _Texts = ({
String title,
String searching,
String hint,
String unnamed,
String forget,
String refused,
IconData icon,
});
_Texts _textsOf(AppLocalizations l10n, DeviceKind kind) => switch (kind) {
DeviceKind.strap => (
title: l10n.strapSheetTitle,
searching: l10n.strapSearching,
hint: l10n.strapSearchingHint,
unnamed: l10n.strapUnnamed,
forget: l10n.strapForget,
refused: l10n.strapRefused,
icon: LucideIcons.heartPulse,
),
DeviceKind.rower => (
title: l10n.rowerSheetTitle,
searching: l10n.rowerSearching,
hint: l10n.rowerSearchingHint,
unnamed: l10n.rowerUnnamed,
forget: l10n.rowerForget,
refused: l10n.rowerRefused,
icon: LucideIcons.waves,
),
};
class const _DeviceSheet({
required final RecordingBloc bloc,
required final DeviceKind kind,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final texts = _textsOf(l10n, kind);
final theme = Theme.of(context);
final remembered = kind == DeviceKind.strap
? bloc.state.strap
: bloc.state.rower;
return SheetScaffold(
title: texts.title,
body: BlocBuilder<DeviceSearchCubit, DeviceSearchState>(
builder: (context, state) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: switch (state.phase) {
DeviceSearchPhase.asking => const [
Center(child: AppLoadingIndicator()),
],
DeviceSearchPhase.refused => [Text(texts.refused)],
DeviceSearchPhase.bluetoothOff => [Text(l10n.strapBluetoothOff)],
DeviceSearchPhase.searching => [
const LinearProgressIndicator(),
const SizedBox(height: 12),
Text(texts.searching, style: theme.textTheme.titleSmall),
Text(
texts.hint,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
for (final device in state.found)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(texts.icon),
title: Text(device.name ?? texts.unnamed),
subtitle: Text(
device.rssi > _nearRssi ? l10n.strapNear : l10n.strapFar,
),
trailing: remembered?.address == device.address
? const Icon(LucideIcons.check)
: null,
onTap: () {
bloc.add(
RecordingEventDeviceChosen(
kind,
KnownDevice(address: device.address, name: device.name),
),
);
Navigator.of(context).pop();
},
),
],
},
),
),
footer: remembered == null
? null
: OutlinedButton(
onPressed: () {
bloc.add(RecordingEventDeviceForgotten(kind));
Navigator.of(context).pop();
},
child: Text(texts.forget),
),
);
}
}

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