feat(train): a ride's distance from the wheel where GPS falls short (ADR 0030, D49)
The speed sensor's wheel gives a ride's distance, splits, progress and live speed where GPS made less than 90 % of its way (a turbo trainer, a tunnel); its circumference starts at 2105 mm, is learnt from rides with 2 km of GPS or typed on the Record sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5c13e6f3c5
commit
a848b71bd4
24 changed files with 527 additions and 27 deletions
File diff suppressed because one or more lines are too long
|
|
@ -88,6 +88,7 @@ class MainActivity : FlutterActivity() {
|
|||
announce = call.argument<String>("announce"),
|
||||
speedSensor = call.argument<String>("speedSensor"),
|
||||
powerMeter = call.argument<String>("powerMeter"),
|
||||
wheelMm = call.argument<Int>("wheelMm"),
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private const val EXTRA_ANNOUNCE = "announce"
|
||||
private const val EXTRA_SPEED_SENSOR = "speedSensor"
|
||||
private const val EXTRA_POWER_METER = "powerMeter"
|
||||
private const val EXTRA_WHEEL_MM = "wheelMm"
|
||||
private const val CHANNEL = "recording"
|
||||
private const val NOTIFICATION_ID = 5301
|
||||
private const val FLUSH_EVERY_MS = 5_000L
|
||||
|
|
@ -88,7 +89,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
* file at [music] says (D47, D48; null: none), announcing each distance
|
||||
* passed in the words of [announce] (JSON, D49; null: never), with the
|
||||
* bike's speed and cadence sensor at [speedSensor] and its power meter at
|
||||
* [powerMeter] (D49; null: none).
|
||||
* [powerMeter] (D49; null: none), its wheel [wheelMm] round.
|
||||
*/
|
||||
fun start(
|
||||
context: Context,
|
||||
|
|
@ -105,6 +106,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
announce: String?,
|
||||
speedSensor: String?,
|
||||
powerMeter: String?,
|
||||
wheelMm: Int?,
|
||||
) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
|
|
@ -122,7 +124,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
.putExtra(EXTRA_MUSIC, music)
|
||||
.putExtra(EXTRA_ANNOUNCE, announce)
|
||||
.putExtra(EXTRA_SPEED_SENSOR, speedSensor)
|
||||
.putExtra(EXTRA_POWER_METER, powerMeter),
|
||||
.putExtra(EXTRA_POWER_METER, powerMeter)
|
||||
.putExtra(EXTRA_WHEEL_MM, wheelMm ?: 0),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +352,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
).also { it.open() }
|
||||
}
|
||||
speedSensorAt?.let { address ->
|
||||
// What the app works the wheel's distance out by (D49).
|
||||
intent.getIntExtra(EXTRA_WHEEL_MM, 0).takeIf { it > 0 }?.let { write("wheelsize $now $it") }
|
||||
speedSensor =
|
||||
NotifyLink(
|
||||
this,
|
||||
|
|
|
|||
|
|
@ -540,5 +540,11 @@
|
|||
"workoutPedalling": "Trittfrequenz",
|
||||
"workoutAverageCadence": "durchschnittliche Trittfrequenz",
|
||||
"workoutNormalisedPower": "normalisierte Leistung",
|
||||
"workoutRpmValue": "{rpm} U/min"
|
||||
"workoutRpmValue": "{rpm} U/min",
|
||||
"recordWheel": "Radumfang",
|
||||
"recordWheelMeasured": "{mm} mm, per GPS gemessen",
|
||||
"recordWheelTyped": "{mm} mm, wie du ihn eingegeben hast",
|
||||
"recordWheelDefault": "{mm} mm, bis GPS ihn auf 2 km Fahrt gemessen hat",
|
||||
"recordWheelHint": "In Millimetern, wie auf dem Reifen (700×25C: 2105); leer, damit GPS ihn misst",
|
||||
"recordWheelSave": "Speichern"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -680,5 +680,14 @@
|
|||
"workoutAverageCadence": "average cadence",
|
||||
"workoutNormalisedPower": "normalised power",
|
||||
"workoutRpmValue": "{rpm} rpm",
|
||||
"@workoutRpmValue": {"placeholders": {"rpm": {"type": "int"}}}
|
||||
"@workoutRpmValue": {"placeholders": {"rpm": {"type": "int"}}},
|
||||
"recordWheel": "Wheel circumference",
|
||||
"recordWheelMeasured": "{mm} mm, measured by GPS",
|
||||
"@recordWheelMeasured": {"placeholders": {"mm": {"type": "int"}}},
|
||||
"recordWheelTyped": "{mm} mm, as you typed it",
|
||||
"@recordWheelTyped": {"placeholders": {"mm": {"type": "int"}}},
|
||||
"recordWheelDefault": "{mm} mm until GPS has measured it on a ride of 2 km",
|
||||
"@recordWheelDefault": {"placeholders": {"mm": {"type": "int"}}},
|
||||
"recordWheelHint": "In millimetres, as the tyre says (700×25C: 2105); empty to have GPS measure it",
|
||||
"recordWheelSave": "Save"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3267,6 +3267,42 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'{rpm} rpm'**
|
||||
String workoutRpmValue(int rpm);
|
||||
|
||||
/// No description provided for @recordWheel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Wheel circumference'**
|
||||
String get recordWheel;
|
||||
|
||||
/// No description provided for @recordWheelMeasured.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{mm} mm, measured by GPS'**
|
||||
String recordWheelMeasured(int mm);
|
||||
|
||||
/// No description provided for @recordWheelTyped.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{mm} mm, as you typed it'**
|
||||
String recordWheelTyped(int mm);
|
||||
|
||||
/// No description provided for @recordWheelDefault.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{mm} mm until GPS has measured it on a ride of 2 km'**
|
||||
String recordWheelDefault(int mm);
|
||||
|
||||
/// No description provided for @recordWheelHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'In millimetres, as the tyre says (700×25C: 2105); empty to have GPS measure it'**
|
||||
String get recordWheelHint;
|
||||
|
||||
/// No description provided for @recordWheelSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get recordWheelSave;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -1959,4 +1959,29 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String workoutRpmValue(int rpm) {
|
||||
return '$rpm U/min';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordWheel => 'Radumfang';
|
||||
|
||||
@override
|
||||
String recordWheelMeasured(int mm) {
|
||||
return '$mm mm, per GPS gemessen';
|
||||
}
|
||||
|
||||
@override
|
||||
String recordWheelTyped(int mm) {
|
||||
return '$mm mm, wie du ihn eingegeben hast';
|
||||
}
|
||||
|
||||
@override
|
||||
String recordWheelDefault(int mm) {
|
||||
return '$mm mm, bis GPS ihn auf 2 km Fahrt gemessen hat';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordWheelHint =>
|
||||
'In Millimetern, wie auf dem Reifen (700×25C: 2105); leer, damit GPS ihn misst';
|
||||
|
||||
@override
|
||||
String get recordWheelSave => 'Speichern';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1947,6 +1947,31 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String workoutRpmValue(int rpm) {
|
||||
return '$rpm rpm';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordWheel => 'Wheel circumference';
|
||||
|
||||
@override
|
||||
String recordWheelMeasured(int mm) {
|
||||
return '$mm mm, measured by GPS';
|
||||
}
|
||||
|
||||
@override
|
||||
String recordWheelTyped(int mm) {
|
||||
return '$mm mm, as you typed it';
|
||||
}
|
||||
|
||||
@override
|
||||
String recordWheelDefault(int mm) {
|
||||
return '$mm mm until GPS has measured it on a ride of 2 km';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recordWheelHint =>
|
||||
'In millimetres, as the tyre says (700×25C: 2105); empty to have GPS measure it';
|
||||
|
||||
@override
|
||||
String get recordWheelSave => 'Save';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -92,6 +92,15 @@ class RecordingBloc(
|
|||
_storeOf(event.kind).save(event.device);
|
||||
emit(state._withDevice(event.kind, event.device));
|
||||
});
|
||||
on<RecordingEventWheelSet>((event, emit) {
|
||||
final sensor = state.speedSensor;
|
||||
if (sensor == null) {
|
||||
return;
|
||||
}
|
||||
final updated = sensor.withWheel(event.mm, typed: true);
|
||||
_storeOf(DeviceKind.speedSensor).save(updated);
|
||||
emit(state._withDevice(DeviceKind.speedSensor, updated));
|
||||
});
|
||||
on<RecordingEventDeviceForgotten>((event, emit) {
|
||||
_storeOf(event.kind).forget();
|
||||
emit(state._withDevice(event.kind, null));
|
||||
|
|
@ -238,6 +247,9 @@ class RecordingBloc(
|
|||
speedSensor: activity == Activity.cycling
|
||||
? state.speedSensor?.address
|
||||
: null,
|
||||
wheelMm: activity == Activity.cycling && state.speedSensor != null
|
||||
? state.speedSensor?.wheelMm ?? defaultWheelMm
|
||||
: null,
|
||||
powerMeter: activity == Activity.cycling
|
||||
? state.powerMeter?.address
|
||||
: null,
|
||||
|
|
@ -298,11 +310,29 @@ class RecordingBloc(
|
|||
RecordingState(
|
||||
phase: RecordingPhase.idle,
|
||||
lastActivity: recording.activity,
|
||||
devices: state.devices,
|
||||
devices: _measured(recording),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The devices, the speed sensor's wheel measured by [recording]'s GPS
|
||||
/// where the person didn't type it (D49): the first measure as it is,
|
||||
/// later ones moving it by a quarter.
|
||||
Map<DeviceKind, KnownDevice> _measured(Recording recording) {
|
||||
final sensor = state.speedSensor;
|
||||
final measured = measuredWheelMm(recording);
|
||||
if (sensor == null || sensor.wheelTyped || measured == null) {
|
||||
return state.devices;
|
||||
}
|
||||
final mm = switch (sensor.wheelMm) {
|
||||
final before? => ((3 * before + measured) / 4).round(),
|
||||
null => measured,
|
||||
};
|
||||
final updated = sensor.withWheel(mm, typed: false);
|
||||
_storeOf(DeviceKind.speedSensor).save(updated);
|
||||
return {...state.devices, DeviceKind.speedSensor: updated};
|
||||
}
|
||||
|
||||
Future<void> _onDiscard(
|
||||
RecordingEventDiscardRequested event,
|
||||
Emitter<RecordingState> emit,
|
||||
|
|
@ -524,6 +554,10 @@ final class const RecordingEventDeviceChosen(
|
|||
final KnownDevice device,
|
||||
) extends RecordingEvent;
|
||||
|
||||
/// The person typed their speed sensor's wheel's circumference [mm] (D49);
|
||||
/// null: GPS measures it again.
|
||||
final class const RecordingEventWheelSet(final int? mm) extends RecordingEvent;
|
||||
|
||||
/// The person forgot their [kind] on this device.
|
||||
final class const RecordingEventDeviceForgotten(final DeviceKind kind)
|
||||
extends RecordingEvent;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
Map<String, Object?>? announce,
|
||||
String? speedSensor,
|
||||
String? powerMeter,
|
||||
int? wheelMm,
|
||||
}) => _channel.invokeMethod<void>('start', {
|
||||
'path': path,
|
||||
'activity': activity.name,
|
||||
|
|
@ -63,6 +64,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
'announce': announce == null ? null : jsonEncode(announce),
|
||||
'speedSensor': speedSensor,
|
||||
'powerMeter': powerMeter,
|
||||
'wheelMm': wheelMm,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -4,15 +4,31 @@ 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.
|
||||
/// The circumference a speed sensor's wheel is taken to have until GPS
|
||||
/// measures it (D49): a road bike's 700×25C.
|
||||
const defaultWheelMm = 2105;
|
||||
|
||||
/// The person's heart-rate strap, rower or bike's sensor on this device
|
||||
/// (ADR 0030, D27, D28, D49): where it is and what it is called, and for a
|
||||
/// speed sensor its wheel's circumference in millimetres, measured by GPS
|
||||
/// or as the person typed it ([wheelTyped]). Never synced, as each phone
|
||||
/// finds it anew.
|
||||
final class const KnownDevice({
|
||||
required final String address,
|
||||
final String? name,
|
||||
final int? wheelMm,
|
||||
final bool wheelTyped = false,
|
||||
}) with Equatable {
|
||||
/// As it is, its wheel [mm] round, typed or measured; null forgets it.
|
||||
KnownDevice withWheel(int? mm, {required bool typed}) => KnownDevice(
|
||||
address: address,
|
||||
name: name,
|
||||
wheelMm: mm,
|
||||
wheelTyped: mm != null && typed,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [address, name];
|
||||
List<Object?> get props => [address, name, wheelMm, wheelTyped];
|
||||
}
|
||||
|
||||
/// Remembers a [KnownDevice] in a file of the person's folder; a file it
|
||||
|
|
@ -24,10 +40,19 @@ class DeviceStore(final File _file) {
|
|||
}
|
||||
try {
|
||||
return switch (jsonDecode(_file.readAsStringSync())) {
|
||||
{'address': final String address, 'name': final String? name} =>
|
||||
{
|
||||
'address': final String address,
|
||||
'name': final String? name,
|
||||
} &&
|
||||
final json =>
|
||||
KnownDevice(
|
||||
address: address,
|
||||
name: name,
|
||||
wheelMm: switch (json['wheelMm']) {
|
||||
final int mm => mm,
|
||||
_ => null,
|
||||
},
|
||||
wheelTyped: json['wheelTyped'] == true,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -37,8 +62,13 @@ class DeviceStore(final File _file) {
|
|||
}
|
||||
}
|
||||
|
||||
void save(KnownDevice strap) => _file.writeAsStringSync(
|
||||
jsonEncode({'address': strap.address, 'name': strap.name}),
|
||||
void save(KnownDevice device) => _file.writeAsStringSync(
|
||||
jsonEncode({
|
||||
'address': device.address,
|
||||
'name': device.name,
|
||||
'wheelMm': ?device.wheelMm,
|
||||
if (device.wheelTyped) 'wheelTyped': true,
|
||||
}),
|
||||
);
|
||||
|
||||
void forget() {
|
||||
|
|
|
|||
|
|
@ -35,13 +35,34 @@ Duration? _splitOf(
|
|||
return Duration(milliseconds: (time.inMilliseconds * 500 / metres).round());
|
||||
}
|
||||
|
||||
/// How far the wheel went over the last 30 s up to [now] and how long that
|
||||
/// took (D49), as [recentOf] has GPS's.
|
||||
(double, Duration)? _wheelRecent(Recording recording, DateTime now) {
|
||||
final from = now.subtract(const Duration(seconds: 30));
|
||||
final recent = [
|
||||
for (final reading in recording.wheel)
|
||||
if (!reading.time.isBefore(from)) reading,
|
||||
];
|
||||
if (recent.length < 2 || recording.wheelMm == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
(recent.last.value - recent.first.value) * recording.wheelMm! / 1000,
|
||||
recent.last.time.difference(recent.first.time),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
final gps = usesGps(recording.activity);
|
||||
final last = recording.pauses.lastOrNull;
|
||||
final paused = last?.paused ?? false;
|
||||
final recent = paused ? null : recentOf(recording, now);
|
||||
final recent = paused
|
||||
? null
|
||||
: rodeByWheel(recording)
|
||||
? _wheelRecent(recording, now)
|
||||
: recentOf(recording, now);
|
||||
final (pace, kmh) = switch (recent) {
|
||||
(final metres, final time) when metres >= 10 && time.inSeconds >= 10 => (
|
||||
Duration(milliseconds: (time.inMilliseconds * 1000 / metres).round()),
|
||||
|
|
|
|||
|
|
@ -95,18 +95,89 @@ Recording recordingUntil(Recording recording, DateTime time) => Recording(
|
|||
for (final sample in recording.rowing)
|
||||
if (!sample.time.isAfter(time)) sample,
|
||||
],
|
||||
wheel: [
|
||||
for (final reading in recording.wheel)
|
||||
if (!reading.time.isAfter(time)) reading,
|
||||
],
|
||||
wheelMm: recording.wheelMm,
|
||||
);
|
||||
|
||||
/// 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,
|
||||
};
|
||||
/// moving (D28); on a bike whose wheel GPS fell short of (D49), the wheel's;
|
||||
/// 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) {
|
||||
if (recording.rowing.isNotEmpty) {
|
||||
return rowedMetresOf(recording);
|
||||
}
|
||||
final gps = _metres(recording);
|
||||
final wheel = wheelMetresOf(recording);
|
||||
if (usesWheel(gps, wheel)) {
|
||||
return wheel!.round();
|
||||
}
|
||||
return gps?.round();
|
||||
}
|
||||
|
||||
/// The wheel's share GPS must reach for its track to give the distance
|
||||
/// (D49): below it, GPS couldn't (a turbo trainer, a tunnel).
|
||||
const _wheelShare = 0.9;
|
||||
|
||||
/// Whether the distance is the wheel's [wheel] metres rather than GPS's
|
||||
/// [gps].
|
||||
bool usesWheel(double? gps, double? wheel) =>
|
||||
wheel != null && wheel > 0 && (gps == null || gps < wheel * _wheelShare);
|
||||
|
||||
/// Whether [recording]'s distance is its wheel's (D49).
|
||||
bool rodeByWheel(Recording recording) =>
|
||||
recording.rowing.isEmpty &&
|
||||
usesWheel(_metres(recording), wheelMetresOf(recording));
|
||||
|
||||
/// The metres the wheel turned while not paused (D49), by its
|
||||
/// circumference; null without either.
|
||||
double? wheelMetresOf(Recording recording) {
|
||||
final mm = recording.wheelMm;
|
||||
final revolutions = _wheelTurns(recording);
|
||||
if (mm == null || revolutions == null) {
|
||||
return null;
|
||||
}
|
||||
return revolutions * mm / 1000;
|
||||
}
|
||||
|
||||
/// The wheel's revolutions while not paused: each reading's growth over
|
||||
/// the one before, those taken while paused left out; null without any.
|
||||
int? _wheelTurns(Recording recording) {
|
||||
if (recording.wheel.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final pauses = recording.pauses;
|
||||
var next = 0;
|
||||
var paused = false;
|
||||
int? before;
|
||||
var turns = 0;
|
||||
for (final reading in recording.wheel) {
|
||||
while (next < pauses.length && !pauses[next].time.isAfter(reading.time)) {
|
||||
paused = pauses[next++].paused;
|
||||
}
|
||||
if (before != null && !paused && reading.value > before) {
|
||||
turns += reading.value - before;
|
||||
}
|
||||
before = reading.value;
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
/// The wheel's circumference, in millimetres, [recording] measured by GPS
|
||||
/// (D49): its track's length over the wheel's revolutions, where it went
|
||||
/// 2 km at least by both and they make a wheel (1.5 to 2.5 m); else null.
|
||||
int? measuredWheelMm(Recording recording) {
|
||||
final gps = _metres(recording);
|
||||
final turns = _wheelTurns(recording);
|
||||
if (gps == null || turns == null || turns == 0 || gps < 2000) {
|
||||
return null;
|
||||
}
|
||||
final mm = (gps * 1000 / turns).round();
|
||||
return mm >= 1500 && mm <= 2500 ? mm : null;
|
||||
}
|
||||
|
||||
/// The metres the rower counted while not paused: each frame's growth over
|
||||
/// the one before, those taken while paused left out.
|
||||
|
|
@ -296,7 +367,16 @@ List<SeriesChunk> seriesOf(Recording recording) {
|
|||
'dpa': [for (final pressure in pressures) pressure.decipascals],
|
||||
},
|
||||
),
|
||||
...readings(SeriesKind.wheel, 'revs', recording.wheel),
|
||||
...chunksOf(
|
||||
SeriesKind.wheel,
|
||||
start: start,
|
||||
times: times(recording.wheel.map((reading) => reading.time)),
|
||||
columns: {
|
||||
'revs': [for (final reading in recording.wheel) reading.value],
|
||||
// The same for each: a column delta-coded costs next to nothing.
|
||||
'mm': [for (final _ in recording.wheel) recording.wheelMm],
|
||||
},
|
||||
),
|
||||
...readings(SeriesKind.pedalling, 'rpm', recording.pedalling),
|
||||
...readings(SeriesKind.power, 'watts', recording.power),
|
||||
...chunksOf(
|
||||
|
|
|
|||
|
|
@ -129,6 +129,9 @@ abstract interface class RecorderPlatform() {
|
|||
/// The bike's speed and cadence sensor and power meter it reads (D49).
|
||||
String? speedSensor,
|
||||
String? powerMeter,
|
||||
|
||||
/// The speed sensor's wheel's circumference in millimetres (D49).
|
||||
int? wheelMm,
|
||||
});
|
||||
|
||||
/// Pauses, or goes on, by hand.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const recordingFileMagic = 'halkyon-train-recording';
|
|||
/// pw <time> <watts>
|
||||
/// csc <time> connecting|connected|lost
|
||||
/// power <time> connecting|connected|lost
|
||||
/// wheelsize <time> <the wheel's circumference, mm>
|
||||
/// loop <time> connecting|connected|lost
|
||||
/// plan <time> <the plan's payload, JSON>
|
||||
/// step <time> <its index in the plan's run>|done
|
||||
|
|
@ -82,6 +83,7 @@ Recording? readRecording(String text) {
|
|||
final power = <Reading>[];
|
||||
final speedSensors = <LinkMark>[];
|
||||
final powerMeters = <LinkMark>[];
|
||||
int? wheelMm;
|
||||
PlanContent? plan;
|
||||
int? planMaxHeartRate;
|
||||
final planSteps = <StepMark>[];
|
||||
|
|
@ -188,6 +190,8 @@ Recording? readRecording(String text) {
|
|||
_ => rowers,
|
||||
}).add(LinkMark(time: time, state: state));
|
||||
}
|
||||
case ['wheelsize', _, final mm]:
|
||||
wheelMm = int.tryParse(mm) ?? wheelMm;
|
||||
case [final kind, _, final value]
|
||||
when kind == 'w' || kind == 'c' || kind == 'pw':
|
||||
if (int.tryParse(value) case final value?) {
|
||||
|
|
@ -228,6 +232,7 @@ Recording? readRecording(String text) {
|
|||
power: power,
|
||||
speedSensors: speedSensors,
|
||||
powerMeters: powerMeters,
|
||||
wheelMm: wheelMm,
|
||||
plan: plan,
|
||||
planSteps: planSteps,
|
||||
planMaxHeartRate: planMaxHeartRate,
|
||||
|
|
|
|||
|
|
@ -181,6 +181,10 @@ final class const Recording({
|
|||
final List<LinkMark> speedSensors = const [],
|
||||
final List<LinkMark> powerMeters = const [],
|
||||
|
||||
/// The wheel's circumference in millimetres the recording started with,
|
||||
/// where it read a speed sensor (D49).
|
||||
final int? wheelMm,
|
||||
|
||||
/// The interval plan it ran, where it ran one, its steps' starts, and
|
||||
/// the highest heart rate its zones were worked out from (D42).
|
||||
final PlanContent? plan,
|
||||
|
|
@ -229,6 +233,7 @@ final class const Recording({
|
|||
power,
|
||||
speedSensors,
|
||||
powerMeters,
|
||||
wheelMm,
|
||||
plan,
|
||||
planSteps,
|
||||
planMaxHeartRate,
|
||||
|
|
|
|||
|
|
@ -125,7 +125,13 @@ class const _DeviceSheet({
|
|||
bloc.add(
|
||||
RecordingEventDeviceChosen(
|
||||
kind,
|
||||
KnownDevice(address: device.address, name: device.name),
|
||||
// The one remembered keeps its wheel (D49).
|
||||
remembered?.address == device.address
|
||||
? remembered!
|
||||
: KnownDevice(
|
||||
address: device.address,
|
||||
name: device.name,
|
||||
),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/announce_speech.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/device_sheet.dart';
|
||||
|
|
@ -200,6 +201,28 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
onTap: () => _chooseDevice(context, kind),
|
||||
),
|
||||
),
|
||||
if (_activity == Activity.cycling)
|
||||
BlocBuilder<RecordingBloc, RecordingState>(
|
||||
bloc: widget.bloc,
|
||||
buildWhen: (before, now) => before.speedSensor != now.speedSensor,
|
||||
builder: (context, state) => switch (state.speedSensor) {
|
||||
null => const SizedBox.shrink(),
|
||||
final sensor => ListTile(
|
||||
leading: const Icon(LucideIcons.circleDot),
|
||||
title: Text(l10n.recordWheel),
|
||||
subtitle: Text(switch (sensor) {
|
||||
KnownDevice(:final wheelMm?, wheelTyped: true) =>
|
||||
l10n.recordWheelTyped(wheelMm),
|
||||
KnownDevice(:final wheelMm?) => l10n.recordWheelMeasured(
|
||||
wheelMm,
|
||||
),
|
||||
_ => l10n.recordWheelDefault(defaultWheelMm),
|
||||
}),
|
||||
trailing: const Icon(LucideIcons.pencil),
|
||||
onTap: () => _typeWheel(context, sensor),
|
||||
),
|
||||
},
|
||||
),
|
||||
BlocBuilder<RecordingBloc, RecordingState>(
|
||||
bloc: widget.bloc,
|
||||
buildWhen: (before, now) => before.strap != now.strap,
|
||||
|
|
@ -346,6 +369,46 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Asks for the wheel's circumference (D49); empty has GPS measure it.
|
||||
Future<void> _typeWheel(BuildContext context, KnownDevice sensor) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final controller = TextEditingController(
|
||||
text: sensor.wheelTyped ? '${sensor.wheelMm}' : '',
|
||||
);
|
||||
final typed = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.recordWheel),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(helperText: l10n.recordWheelHint),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text),
|
||||
child: Text(l10n.recordWheelSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
if (typed == null) {
|
||||
return;
|
||||
}
|
||||
final mm = int.tryParse(typed.trim());
|
||||
widget.bloc.add(
|
||||
RecordingEventWheelSet(
|
||||
mm != null && mm >= 1000 && mm <= 3000 ? mm : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _chooseDevice(BuildContext context, DeviceKind kind) =>
|
||||
showDeviceSheet(
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@ WorkoutAnalysis analyse(WorkoutContent content, List<SeriesChunk> series) {
|
|||
// How far it had gone over its moving time: by the track, or the rower.
|
||||
final progress = rowed
|
||||
? _rowedProgress(recording)
|
||||
: rodeByWheel(recording)
|
||||
? _wheelProgress(recording)
|
||||
: [for (final p in points) (metres: p.metres, moving: p.moving)];
|
||||
final byDistance = progress.length > 1 && progress.last.metres > 0;
|
||||
double xAt(Duration moving) =>
|
||||
|
|
@ -391,6 +393,26 @@ List<_Progress> _rowedProgress(Recording recording) {
|
|||
return progress;
|
||||
}
|
||||
|
||||
/// The wheel's metres over its moving time (D49), by its circumference.
|
||||
List<_Progress> _wheelProgress(Recording recording) {
|
||||
final mm = recording.wheelMm ?? 0;
|
||||
final progress = <_Progress>[];
|
||||
int? before;
|
||||
var turns = 0;
|
||||
for (final (reading, moving) in _whileMoving(
|
||||
recording,
|
||||
recording.wheel,
|
||||
(reading) => reading.time,
|
||||
)) {
|
||||
if (before != null && reading.value > before) {
|
||||
turns += reading.value - before;
|
||||
}
|
||||
before = reading.value;
|
||||
progress.add((metres: turns * mm / 1000, moving: moving));
|
||||
}
|
||||
return progress;
|
||||
}
|
||||
|
||||
/// The strokes rowed while moving.
|
||||
int _rowedStrokes(Recording recording) {
|
||||
int? before;
|
||||
|
|
@ -457,6 +479,7 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
|
|||
final heartRates = <HeartRate>[];
|
||||
final rowing = <RowingSample>[];
|
||||
final pressures = <Pressure>[];
|
||||
int? wheelMm;
|
||||
final readings = {
|
||||
SeriesKind.wheel: <Reading>[],
|
||||
SeriesKind.pedalling: <Reading>[],
|
||||
|
|
@ -523,6 +546,9 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
|
|||
}
|
||||
}
|
||||
case SeriesKind.wheel || SeriesKind.pedalling || SeriesKind.power:
|
||||
if (chunk.kind == SeriesKind.wheel) {
|
||||
wheelMm ??= columns['mm']?.nonNulls.firstOrNull;
|
||||
}
|
||||
final column = switch (chunk.kind) {
|
||||
SeriesKind.wheel => 'revs',
|
||||
SeriesKind.pedalling => 'rpm',
|
||||
|
|
@ -557,6 +583,7 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
|
|||
wheel: readings[SeriesKind.wheel]!,
|
||||
pedalling: readings[SeriesKind.pedalling]!,
|
||||
power: readings[SeriesKind.power]!,
|
||||
wheelMm: wheelMm,
|
||||
endedAt: content.endedAt,
|
||||
);
|
||||
}
|
||||
|
|
@ -683,6 +710,7 @@ Recording withBarometer(Recording recording) {
|
|||
power: recording.power,
|
||||
speedSensors: recording.speedSensors,
|
||||
powerMeters: recording.powerMeters,
|
||||
wheelMm: recording.wheelMm,
|
||||
plan: recording.plan,
|
||||
planSteps: recording.planSteps,
|
||||
planMaxHeartRate: recording.planMaxHeartRate,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,23 @@ void main() {
|
|||
expect(store.read(), isNull);
|
||||
});
|
||||
|
||||
test('a speed sensor keeps its wheel, typed or measured (D49)', () {
|
||||
final store = DeviceStore(File('${folder.path}/speed.json'));
|
||||
const sensor = KnownDevice(address: 'AA:01', name: 'Speed');
|
||||
store.save(sensor.withWheel(2096, typed: true));
|
||||
expect(
|
||||
store.read(),
|
||||
const KnownDevice(
|
||||
address: 'AA:01',
|
||||
name: 'Speed',
|
||||
wheelMm: 2096,
|
||||
wheelTyped: true,
|
||||
),
|
||||
);
|
||||
store.save(sensor.withWheel(null, typed: true));
|
||||
expect(store.read(), sensor);
|
||||
});
|
||||
|
||||
test("a file it can't read is no strap", () {
|
||||
final file = File('${folder.path}/strap.json')..writeAsStringSync('{');
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue