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
25 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"),
|
announce = call.argument<String>("announce"),
|
||||||
speedSensor = call.argument<String>("speedSensor"),
|
speedSensor = call.argument<String>("speedSensor"),
|
||||||
powerMeter = call.argument<String>("powerMeter"),
|
powerMeter = call.argument<String>("powerMeter"),
|
||||||
|
wheelMm = call.argument<Int>("wheelMm"),
|
||||||
)
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
private const val EXTRA_ANNOUNCE = "announce"
|
private const val EXTRA_ANNOUNCE = "announce"
|
||||||
private const val EXTRA_SPEED_SENSOR = "speedSensor"
|
private const val EXTRA_SPEED_SENSOR = "speedSensor"
|
||||||
private const val EXTRA_POWER_METER = "powerMeter"
|
private const val EXTRA_POWER_METER = "powerMeter"
|
||||||
|
private const val EXTRA_WHEEL_MM = "wheelMm"
|
||||||
private const val CHANNEL = "recording"
|
private const val CHANNEL = "recording"
|
||||||
private const val NOTIFICATION_ID = 5301
|
private const val NOTIFICATION_ID = 5301
|
||||||
private const val FLUSH_EVERY_MS = 5_000L
|
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
|
* file at [music] says (D47, D48; null: none), announcing each distance
|
||||||
* passed in the words of [announce] (JSON, D49; null: never), with the
|
* 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
|
* 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(
|
fun start(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -105,6 +106,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
announce: String?,
|
announce: String?,
|
||||||
speedSensor: String?,
|
speedSensor: String?,
|
||||||
powerMeter: String?,
|
powerMeter: String?,
|
||||||
|
wheelMm: Int?,
|
||||||
) {
|
) {
|
||||||
ContextCompat.startForegroundService(
|
ContextCompat.startForegroundService(
|
||||||
context,
|
context,
|
||||||
|
|
@ -122,7 +124,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
.putExtra(EXTRA_MUSIC, music)
|
.putExtra(EXTRA_MUSIC, music)
|
||||||
.putExtra(EXTRA_ANNOUNCE, announce)
|
.putExtra(EXTRA_ANNOUNCE, announce)
|
||||||
.putExtra(EXTRA_SPEED_SENSOR, speedSensor)
|
.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() }
|
).also { it.open() }
|
||||||
}
|
}
|
||||||
speedSensorAt?.let { address ->
|
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 =
|
speedSensor =
|
||||||
NotifyLink(
|
NotifyLink(
|
||||||
this,
|
this,
|
||||||
|
|
|
||||||
|
|
@ -540,5 +540,11 @@
|
||||||
"workoutPedalling": "Trittfrequenz",
|
"workoutPedalling": "Trittfrequenz",
|
||||||
"workoutAverageCadence": "durchschnittliche Trittfrequenz",
|
"workoutAverageCadence": "durchschnittliche Trittfrequenz",
|
||||||
"workoutNormalisedPower": "normalisierte Leistung",
|
"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",
|
"workoutAverageCadence": "average cadence",
|
||||||
"workoutNormalisedPower": "normalised power",
|
"workoutNormalisedPower": "normalised power",
|
||||||
"workoutRpmValue": "{rpm} rpm",
|
"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:
|
/// In en, this message translates to:
|
||||||
/// **'{rpm} rpm'**
|
/// **'{rpm} rpm'**
|
||||||
String workoutRpmValue(int 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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -1959,4 +1959,29 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String workoutRpmValue(int rpm) {
|
String workoutRpmValue(int rpm) {
|
||||||
return '$rpm U/min';
|
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) {
|
String workoutRpmValue(int rpm) {
|
||||||
return '$rpm 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`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,15 @@ class RecordingBloc(
|
||||||
_storeOf(event.kind).save(event.device);
|
_storeOf(event.kind).save(event.device);
|
||||||
emit(state._withDevice(event.kind, 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) {
|
on<RecordingEventDeviceForgotten>((event, emit) {
|
||||||
_storeOf(event.kind).forget();
|
_storeOf(event.kind).forget();
|
||||||
emit(state._withDevice(event.kind, null));
|
emit(state._withDevice(event.kind, null));
|
||||||
|
|
@ -238,6 +247,9 @@ class RecordingBloc(
|
||||||
speedSensor: activity == Activity.cycling
|
speedSensor: activity == Activity.cycling
|
||||||
? state.speedSensor?.address
|
? state.speedSensor?.address
|
||||||
: null,
|
: null,
|
||||||
|
wheelMm: activity == Activity.cycling && state.speedSensor != null
|
||||||
|
? state.speedSensor?.wheelMm ?? defaultWheelMm
|
||||||
|
: null,
|
||||||
powerMeter: activity == Activity.cycling
|
powerMeter: activity == Activity.cycling
|
||||||
? state.powerMeter?.address
|
? state.powerMeter?.address
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -298,11 +310,29 @@ class RecordingBloc(
|
||||||
RecordingState(
|
RecordingState(
|
||||||
phase: RecordingPhase.idle,
|
phase: RecordingPhase.idle,
|
||||||
lastActivity: recording.activity,
|
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(
|
Future<void> _onDiscard(
|
||||||
RecordingEventDiscardRequested event,
|
RecordingEventDiscardRequested event,
|
||||||
Emitter<RecordingState> emit,
|
Emitter<RecordingState> emit,
|
||||||
|
|
@ -524,6 +554,10 @@ final class const RecordingEventDeviceChosen(
|
||||||
final KnownDevice device,
|
final KnownDevice device,
|
||||||
) extends RecordingEvent;
|
) 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.
|
/// The person forgot their [kind] on this device.
|
||||||
final class const RecordingEventDeviceForgotten(final DeviceKind kind)
|
final class const RecordingEventDeviceForgotten(final DeviceKind kind)
|
||||||
extends RecordingEvent;
|
extends RecordingEvent;
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
Map<String, Object?>? announce,
|
Map<String, Object?>? announce,
|
||||||
String? speedSensor,
|
String? speedSensor,
|
||||||
String? powerMeter,
|
String? powerMeter,
|
||||||
|
int? wheelMm,
|
||||||
}) => _channel.invokeMethod<void>('start', {
|
}) => _channel.invokeMethod<void>('start', {
|
||||||
'path': path,
|
'path': path,
|
||||||
'activity': activity.name,
|
'activity': activity.name,
|
||||||
|
|
@ -63,6 +64,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
'announce': announce == null ? null : jsonEncode(announce),
|
'announce': announce == null ? null : jsonEncode(announce),
|
||||||
'speedSensor': speedSensor,
|
'speedSensor': speedSensor,
|
||||||
'powerMeter': powerMeter,
|
'powerMeter': powerMeter,
|
||||||
|
'wheelMm': wheelMm,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,31 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
/// The person's heart-rate strap or rower on this device (ADR 0030, D27,
|
/// The circumference a speed sensor's wheel is taken to have until GPS
|
||||||
/// D28): where it is and what it is called. Never synced, as each phone finds
|
/// measures it (D49): a road bike's 700×25C.
|
||||||
/// it anew.
|
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({
|
final class const KnownDevice({
|
||||||
required final String address,
|
required final String address,
|
||||||
final String? name,
|
final String? name,
|
||||||
|
final int? wheelMm,
|
||||||
|
final bool wheelTyped = false,
|
||||||
}) with Equatable {
|
}) 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
|
@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
|
/// Remembers a [KnownDevice] in a file of the person's folder; a file it
|
||||||
|
|
@ -24,10 +40,19 @@ class DeviceStore(final File _file) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return switch (jsonDecode(_file.readAsStringSync())) {
|
return switch (jsonDecode(_file.readAsStringSync())) {
|
||||||
{'address': final String address, 'name': final String? name} =>
|
{
|
||||||
|
'address': final String address,
|
||||||
|
'name': final String? name,
|
||||||
|
} &&
|
||||||
|
final json =>
|
||||||
KnownDevice(
|
KnownDevice(
|
||||||
address: address,
|
address: address,
|
||||||
name: name,
|
name: name,
|
||||||
|
wheelMm: switch (json['wheelMm']) {
|
||||||
|
final int mm => mm,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
wheelTyped: json['wheelTyped'] == true,
|
||||||
),
|
),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
@ -37,8 +62,13 @@ class DeviceStore(final File _file) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void save(KnownDevice strap) => _file.writeAsStringSync(
|
void save(KnownDevice device) => _file.writeAsStringSync(
|
||||||
jsonEncode({'address': strap.address, 'name': strap.name}),
|
jsonEncode({
|
||||||
|
'address': device.address,
|
||||||
|
'name': device.name,
|
||||||
|
'wheelMm': ?device.wheelMm,
|
||||||
|
if (device.wheelTyped) 'wheelTyped': true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
void forget() {
|
void forget() {
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,34 @@ Duration? _splitOf(
|
||||||
return Duration(milliseconds: (time.inMilliseconds * 500 / metres).round());
|
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),
|
/// What the recording screen shows of [recording] at [now] (ADR 0030, D21),
|
||||||
/// worked out as a saved workout's totals are (D16, D20).
|
/// worked out as a saved workout's totals are (D16, D20).
|
||||||
LiveRecording liveOf(Recording recording, DateTime now) {
|
LiveRecording liveOf(Recording recording, DateTime now) {
|
||||||
final gps = usesGps(recording.activity);
|
final gps = usesGps(recording.activity);
|
||||||
final last = recording.pauses.lastOrNull;
|
final last = recording.pauses.lastOrNull;
|
||||||
final paused = last?.paused ?? false;
|
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 (pace, kmh) = switch (recent) {
|
||||||
(final metres, final time) when metres >= 10 && time.inSeconds >= 10 => (
|
(final metres, final time) when metres >= 10 && time.inSeconds >= 10 => (
|
||||||
Duration(milliseconds: (time.inMilliseconds * 1000 / metres).round()),
|
Duration(milliseconds: (time.inMilliseconds * 1000 / metres).round()),
|
||||||
|
|
|
||||||
|
|
@ -95,18 +95,89 @@ Recording recordingUntil(Recording recording, DateTime time) => Recording(
|
||||||
for (final sample in recording.rowing)
|
for (final sample in recording.rowing)
|
||||||
if (!sample.time.isAfter(time)) sample,
|
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
|
/// 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
|
/// moving (D28); on a bike whose wheel GPS fell short of (D49), the wheel's;
|
||||||
/// while it moves, the imprecise ones left out, starting afresh after each
|
/// else the track's length, null without fixes, from fix to fix while it
|
||||||
/// pause.
|
/// moves, the imprecise ones left out, starting afresh after each pause.
|
||||||
int? distanceOf(Recording recording) => recording.rowing.isNotEmpty
|
int? distanceOf(Recording recording) {
|
||||||
? rowedMetresOf(recording)
|
if (recording.rowing.isNotEmpty) {
|
||||||
: switch (_metres(recording)) {
|
return rowedMetresOf(recording);
|
||||||
final metres? => metres.round(),
|
}
|
||||||
null => null,
|
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 metres the rower counted while not paused: each frame's growth over
|
||||||
/// the one before, those taken while paused left out.
|
/// 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],
|
'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.pedalling, 'rpm', recording.pedalling),
|
||||||
...readings(SeriesKind.power, 'watts', recording.power),
|
...readings(SeriesKind.power, 'watts', recording.power),
|
||||||
...chunksOf(
|
...chunksOf(
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,9 @@ abstract interface class RecorderPlatform() {
|
||||||
/// The bike's speed and cadence sensor and power meter it reads (D49).
|
/// The bike's speed and cadence sensor and power meter it reads (D49).
|
||||||
String? speedSensor,
|
String? speedSensor,
|
||||||
String? powerMeter,
|
String? powerMeter,
|
||||||
|
|
||||||
|
/// The speed sensor's wheel's circumference in millimetres (D49).
|
||||||
|
int? wheelMm,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Pauses, or goes on, by hand.
|
/// Pauses, or goes on, by hand.
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const recordingFileMagic = 'halkyon-train-recording';
|
||||||
/// pw <time> <watts>
|
/// pw <time> <watts>
|
||||||
/// csc <time> connecting|connected|lost
|
/// csc <time> connecting|connected|lost
|
||||||
/// power <time> connecting|connected|lost
|
/// power <time> connecting|connected|lost
|
||||||
|
/// wheelsize <time> <the wheel's circumference, mm>
|
||||||
/// loop <time> connecting|connected|lost
|
/// loop <time> connecting|connected|lost
|
||||||
/// plan <time> <the plan's payload, JSON>
|
/// plan <time> <the plan's payload, JSON>
|
||||||
/// step <time> <its index in the plan's run>|done
|
/// step <time> <its index in the plan's run>|done
|
||||||
|
|
@ -82,6 +83,7 @@ Recording? readRecording(String text) {
|
||||||
final power = <Reading>[];
|
final power = <Reading>[];
|
||||||
final speedSensors = <LinkMark>[];
|
final speedSensors = <LinkMark>[];
|
||||||
final powerMeters = <LinkMark>[];
|
final powerMeters = <LinkMark>[];
|
||||||
|
int? wheelMm;
|
||||||
PlanContent? plan;
|
PlanContent? plan;
|
||||||
int? planMaxHeartRate;
|
int? planMaxHeartRate;
|
||||||
final planSteps = <StepMark>[];
|
final planSteps = <StepMark>[];
|
||||||
|
|
@ -188,6 +190,8 @@ Recording? readRecording(String text) {
|
||||||
_ => rowers,
|
_ => rowers,
|
||||||
}).add(LinkMark(time: time, state: state));
|
}).add(LinkMark(time: time, state: state));
|
||||||
}
|
}
|
||||||
|
case ['wheelsize', _, final mm]:
|
||||||
|
wheelMm = int.tryParse(mm) ?? wheelMm;
|
||||||
case [final kind, _, final value]
|
case [final kind, _, final value]
|
||||||
when kind == 'w' || kind == 'c' || kind == 'pw':
|
when kind == 'w' || kind == 'c' || kind == 'pw':
|
||||||
if (int.tryParse(value) case final value?) {
|
if (int.tryParse(value) case final value?) {
|
||||||
|
|
@ -228,6 +232,7 @@ Recording? readRecording(String text) {
|
||||||
power: power,
|
power: power,
|
||||||
speedSensors: speedSensors,
|
speedSensors: speedSensors,
|
||||||
powerMeters: powerMeters,
|
powerMeters: powerMeters,
|
||||||
|
wheelMm: wheelMm,
|
||||||
plan: plan,
|
plan: plan,
|
||||||
planSteps: planSteps,
|
planSteps: planSteps,
|
||||||
planMaxHeartRate: planMaxHeartRate,
|
planMaxHeartRate: planMaxHeartRate,
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,10 @@ final class const Recording({
|
||||||
final List<LinkMark> speedSensors = const [],
|
final List<LinkMark> speedSensors = const [],
|
||||||
final List<LinkMark> powerMeters = 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 interval plan it ran, where it ran one, its steps' starts, and
|
||||||
/// the highest heart rate its zones were worked out from (D42).
|
/// the highest heart rate its zones were worked out from (D42).
|
||||||
final PlanContent? plan,
|
final PlanContent? plan,
|
||||||
|
|
@ -229,6 +233,7 @@ final class const Recording({
|
||||||
power,
|
power,
|
||||||
speedSensors,
|
speedSensors,
|
||||||
powerMeters,
|
powerMeters,
|
||||||
|
wheelMm,
|
||||||
plan,
|
plan,
|
||||||
planSteps,
|
planSteps,
|
||||||
planMaxHeartRate,
|
planMaxHeartRate,
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,13 @@ class const _DeviceSheet({
|
||||||
bloc.add(
|
bloc.add(
|
||||||
RecordingEventDeviceChosen(
|
RecordingEventDeviceChosen(
|
||||||
kind,
|
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();
|
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/model/plan.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.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/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/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/announce_speech.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/device_sheet.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),
|
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>(
|
BlocBuilder<RecordingBloc, RecordingState>(
|
||||||
bloc: widget.bloc,
|
bloc: widget.bloc,
|
||||||
buildWhen: (before, now) => before.strap != now.strap,
|
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) =>
|
Future<void> _chooseDevice(BuildContext context, DeviceKind kind) =>
|
||||||
showDeviceSheet(
|
showDeviceSheet(
|
||||||
context,
|
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.
|
// How far it had gone over its moving time: by the track, or the rower.
|
||||||
final progress = rowed
|
final progress = rowed
|
||||||
? _rowedProgress(recording)
|
? _rowedProgress(recording)
|
||||||
|
: rodeByWheel(recording)
|
||||||
|
? _wheelProgress(recording)
|
||||||
: [for (final p in points) (metres: p.metres, moving: p.moving)];
|
: [for (final p in points) (metres: p.metres, moving: p.moving)];
|
||||||
final byDistance = progress.length > 1 && progress.last.metres > 0;
|
final byDistance = progress.length > 1 && progress.last.metres > 0;
|
||||||
double xAt(Duration moving) =>
|
double xAt(Duration moving) =>
|
||||||
|
|
@ -391,6 +393,26 @@ List<_Progress> _rowedProgress(Recording recording) {
|
||||||
return progress;
|
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.
|
/// The strokes rowed while moving.
|
||||||
int _rowedStrokes(Recording recording) {
|
int _rowedStrokes(Recording recording) {
|
||||||
int? before;
|
int? before;
|
||||||
|
|
@ -457,6 +479,7 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
|
||||||
final heartRates = <HeartRate>[];
|
final heartRates = <HeartRate>[];
|
||||||
final rowing = <RowingSample>[];
|
final rowing = <RowingSample>[];
|
||||||
final pressures = <Pressure>[];
|
final pressures = <Pressure>[];
|
||||||
|
int? wheelMm;
|
||||||
final readings = {
|
final readings = {
|
||||||
SeriesKind.wheel: <Reading>[],
|
SeriesKind.wheel: <Reading>[],
|
||||||
SeriesKind.pedalling: <Reading>[],
|
SeriesKind.pedalling: <Reading>[],
|
||||||
|
|
@ -523,6 +546,9 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case SeriesKind.wheel || SeriesKind.pedalling || SeriesKind.power:
|
case SeriesKind.wheel || SeriesKind.pedalling || SeriesKind.power:
|
||||||
|
if (chunk.kind == SeriesKind.wheel) {
|
||||||
|
wheelMm ??= columns['mm']?.nonNulls.firstOrNull;
|
||||||
|
}
|
||||||
final column = switch (chunk.kind) {
|
final column = switch (chunk.kind) {
|
||||||
SeriesKind.wheel => 'revs',
|
SeriesKind.wheel => 'revs',
|
||||||
SeriesKind.pedalling => 'rpm',
|
SeriesKind.pedalling => 'rpm',
|
||||||
|
|
@ -557,6 +583,7 @@ Recording recordingOf(WorkoutContent content, List<SeriesChunk> series) {
|
||||||
wheel: readings[SeriesKind.wheel]!,
|
wheel: readings[SeriesKind.wheel]!,
|
||||||
pedalling: readings[SeriesKind.pedalling]!,
|
pedalling: readings[SeriesKind.pedalling]!,
|
||||||
power: readings[SeriesKind.power]!,
|
power: readings[SeriesKind.power]!,
|
||||||
|
wheelMm: wheelMm,
|
||||||
endedAt: content.endedAt,
|
endedAt: content.endedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -683,6 +710,7 @@ Recording withBarometer(Recording recording) {
|
||||||
power: recording.power,
|
power: recording.power,
|
||||||
speedSensors: recording.speedSensors,
|
speedSensors: recording.speedSensors,
|
||||||
powerMeters: recording.powerMeters,
|
powerMeters: recording.powerMeters,
|
||||||
|
wheelMm: recording.wheelMm,
|
||||||
plan: recording.plan,
|
plan: recording.plan,
|
||||||
planSteps: recording.planSteps,
|
planSteps: recording.planSteps,
|
||||||
planMaxHeartRate: recording.planMaxHeartRate,
|
planMaxHeartRate: recording.planMaxHeartRate,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,23 @@ void main() {
|
||||||
expect(store.read(), isNull);
|
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", () {
|
test("a file it can't read is no strap", () {
|
||||||
final file = File('${folder.path}/strap.json')..writeAsStringSync('{');
|
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