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:
parent
de5fd43e06
commit
f3d08e4e69
29 changed files with 2266 additions and 514 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue