feat(train): each km announced on foot, each 5 km by bike (ADR 0030, D49)
Step 11 takes up what OpenTracks does that Train didn't (D49). 11a: a recording announces each kilometre on foot and each 5 km by bike, never on a rower, where the Record sheet's switch is on for the activity (remembered in the person's announce.json, off at first): the distance, the moving time, the split's pace or speed and the heart rate. The app hands the recorder the words in its language; Announcer.kt counts the moving time and the distance, Cues.kt speaks the line after a cue still speaking. Not tried on a phone yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
d5b367e930
commit
db7e0b7bcd
13 changed files with 782 additions and 11 deletions
|
|
@ -505,5 +505,14 @@
|
|||
"recordMusic": "Musik",
|
||||
"recordMusicOn": "Musik",
|
||||
"recordMetronome": "Metronom",
|
||||
"recordMusicOff": "Aus"
|
||||
"recordMusicOff": "Aus",
|
||||
"recordAnnounce": "Ansagen",
|
||||
"recordAnnounceFoot": "Jeden Kilometer: Zeit, Pace und Puls",
|
||||
"recordAnnounceBike": "Alle 5 km: Zeit, Tempo und Puls",
|
||||
"announceKilometres": "{count, plural, =1{1 Kilometer} other{{count} Kilometer}}",
|
||||
"announceHours": "{count, plural, =1{1 Stunde} other{{count} Stunden}}",
|
||||
"announceTime": "Zeit {time}",
|
||||
"announcePace": "Pace {pace} pro Kilometer",
|
||||
"announceSpeed": "Tempo {speed} Kilometer pro Stunde",
|
||||
"announceHeartRate": "Puls {bpm}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -636,5 +636,22 @@
|
|||
"recordMusicOn": "Music",
|
||||
"@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."},
|
||||
"recordMetronome": "Metronome",
|
||||
"recordMusicOff": "Off"
|
||||
"recordMusicOff": "Off",
|
||||
"recordAnnounce": "Announcements",
|
||||
"recordAnnounceFoot": "Every kilometre: time, pace and heart rate",
|
||||
"@recordAnnounceFoot": {"description": "The switch's hint on foot (D49)."},
|
||||
"recordAnnounceBike": "Every 5 km: time, speed and heart rate",
|
||||
"@recordAnnounceBike": {"description": "The switch's hint on a bike (D49)."},
|
||||
"announceKilometres": "{count, plural, =1{1 kilometre} other{{count} kilometres}}",
|
||||
"@announceKilometres": {"placeholders": {"count": {"type": "int"}}},
|
||||
"announceHours": "{count, plural, =1{1 hour} other{{count} hours}}",
|
||||
"@announceHours": {"placeholders": {"count": {"type": "int"}}},
|
||||
"announceTime": "Time {time}",
|
||||
"@announceTime": {"placeholders": {"time": {"type": "String"}}},
|
||||
"announcePace": "Pace {pace} a kilometre",
|
||||
"@announcePace": {"placeholders": {"pace": {"type": "String"}}},
|
||||
"announceSpeed": "Speed {speed} kilometres an hour",
|
||||
"@announceSpeed": {"placeholders": {"speed": {"type": "String"}}},
|
||||
"announceHeartRate": "Heart rate {bpm}",
|
||||
"@announceHeartRate": {"placeholders": {"bpm": {"type": "String"}}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3057,6 +3057,60 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Off'**
|
||||
String get recordMusicOff;
|
||||
|
||||
/// No description provided for @recordAnnounce.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Announcements'**
|
||||
String get recordAnnounce;
|
||||
|
||||
/// The switch's hint on foot (D49).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every kilometre: time, pace and heart rate'**
|
||||
String get recordAnnounceFoot;
|
||||
|
||||
/// The switch's hint on a bike (D49).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Every 5 km: time, speed and heart rate'**
|
||||
String get recordAnnounceBike;
|
||||
|
||||
/// No description provided for @announceKilometres.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 kilometre} other{{count} kilometres}}'**
|
||||
String announceKilometres(int count);
|
||||
|
||||
/// No description provided for @announceHours.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 hour} other{{count} hours}}'**
|
||||
String announceHours(int count);
|
||||
|
||||
/// No description provided for @announceTime.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Time {time}'**
|
||||
String announceTime(String time);
|
||||
|
||||
/// No description provided for @announcePace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pace {pace} a kilometre'**
|
||||
String announcePace(String pace);
|
||||
|
||||
/// No description provided for @announceSpeed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Speed {speed} kilometres an hour'**
|
||||
String announceSpeed(String speed);
|
||||
|
||||
/// No description provided for @announceHeartRate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Heart rate {bpm}'**
|
||||
String announceHeartRate(String bpm);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -1821,4 +1821,55 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get recordMusicOff => 'Aus';
|
||||
|
||||
@override
|
||||
String get recordAnnounce => 'Ansagen';
|
||||
|
||||
@override
|
||||
String get recordAnnounceFoot => 'Jeden Kilometer: Zeit, Pace und Puls';
|
||||
|
||||
@override
|
||||
String get recordAnnounceBike => 'Alle 5 km: Zeit, Tempo und Puls';
|
||||
|
||||
@override
|
||||
String announceKilometres(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Kilometer',
|
||||
one: '1 Kilometer',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String announceHours(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Stunden',
|
||||
one: '1 Stunde',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String announceTime(String time) {
|
||||
return 'Zeit $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String announcePace(String pace) {
|
||||
return 'Pace $pace pro Kilometer';
|
||||
}
|
||||
|
||||
@override
|
||||
String announceSpeed(String speed) {
|
||||
return 'Tempo $speed Kilometer pro Stunde';
|
||||
}
|
||||
|
||||
@override
|
||||
String announceHeartRate(String bpm) {
|
||||
return 'Puls $bpm';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1809,6 +1809,57 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get recordMusicOff => 'Off';
|
||||
|
||||
@override
|
||||
String get recordAnnounce => 'Announcements';
|
||||
|
||||
@override
|
||||
String get recordAnnounceFoot => 'Every kilometre: time, pace and heart rate';
|
||||
|
||||
@override
|
||||
String get recordAnnounceBike => 'Every 5 km: time, speed and heart rate';
|
||||
|
||||
@override
|
||||
String announceKilometres(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count kilometres',
|
||||
one: '1 kilometre',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String announceHours(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count hours',
|
||||
one: '1 hour',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String announceTime(String time) {
|
||||
return 'Time $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String announcePace(String pace) {
|
||||
return 'Pace $pace a kilometre';
|
||||
}
|
||||
|
||||
@override
|
||||
String announceSpeed(String speed) {
|
||||
return 'Speed $speed kilometres an hour';
|
||||
}
|
||||
|
||||
@override
|
||||
String announceHeartRate(String bpm) {
|
||||
return 'Heart rate $bpm';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import 'package:halkyon_train_flutter/src/feature/music/data/recorder_music.dart
|
|||
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/announce_speech.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
|
||||
|
|
@ -182,6 +183,11 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
|
|||
);
|
||||
return music == null ? null : (music: music, cadence: cadence);
|
||||
},
|
||||
announcementsOf: (activity) => recorderAnnouncementsOf(
|
||||
AppLocalizations.of(context),
|
||||
Localizations.localeOf(context).toLanguageTag(),
|
||||
activity,
|
||||
),
|
||||
routeOf: (route, activity) => recorderRouteOf(
|
||||
AppLocalizations.of(context),
|
||||
Localizations.localeOf(context).toLanguageTag(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.da
|
|||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/announce_store.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/route/data/route_files.dart';
|
||||
|
|
@ -46,12 +47,13 @@ import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
|||
|
||||
/// 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.
|
||||
/// folder, and the activities they have announced (D49).
|
||||
typedef RecordingDevice = ({
|
||||
RecorderPlatform platform,
|
||||
File file,
|
||||
DeviceStore straps,
|
||||
DeviceStore rowers,
|
||||
AnnounceStore announces,
|
||||
});
|
||||
|
||||
/// A signed-in person's dependencies: who they are and their workouts, on
|
||||
|
|
@ -452,6 +454,9 @@ Future<TrainPerson> _openPersonData(
|
|||
file: File(p.join(folder.path, 'recording.txt')),
|
||||
straps: DeviceStore(File(p.join(folder.path, 'strap.json'))),
|
||||
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
|
||||
announces: AnnounceStore(
|
||||
File(p.join(folder.path, 'announce.json')),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
health: health,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ class RecordingBloc(
|
|||
/// recorded, in the app's language.
|
||||
final Map<String, Object?> Function(RouteContent route, Activity activity)?
|
||||
routeOf,
|
||||
|
||||
/// The words a recording of an activity announces each distance in
|
||||
/// (`recorderAnnouncementsOf`, D49), in the app's language; null where it
|
||||
/// announces none.
|
||||
final Map<String, Object?>? Function(Activity activity)? announcementsOf,
|
||||
}) extends Bloc<RecordingEvent, RecordingState> {
|
||||
this : super(const RecordingState()) {
|
||||
on<RecordingEventOpened>(_onOpened);
|
||||
|
|
@ -237,6 +242,7 @@ class RecordingBloc(
|
|||
plan: plan,
|
||||
route: route,
|
||||
music: music?.music,
|
||||
announce: event.announce ? announcementsOf?.call(activity) : null,
|
||||
);
|
||||
_shown = _keyOf(notice);
|
||||
_follow();
|
||||
|
|
@ -496,7 +502,8 @@ final class const RecordingEventOpened() extends RecordingEvent;
|
|||
/// The person starts recording [activity], pausing by itself where
|
||||
/// [autoPause] (D16), with their strap unless not [useStrap] (D27), running
|
||||
/// [plan] where one was chosen (D42), following [route] where one was
|
||||
/// (D45), playing [music] (D48).
|
||||
/// (D45), playing [music] (D48), announcing each distance passed where
|
||||
/// [announce] (D49).
|
||||
final class const RecordingEventStartRequested(
|
||||
final Activity activity, {
|
||||
final bool autoPause = true,
|
||||
|
|
@ -504,6 +511,7 @@ final class const RecordingEventStartRequested(
|
|||
final PlanContent? plan,
|
||||
final RouteContent? route,
|
||||
final MusicChoice music = MusicChoice.off,
|
||||
final bool announce = false,
|
||||
}) extends RecordingEvent;
|
||||
|
||||
/// The person chose [device] as their [kind] on this device.
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
Map<String, Object?>? plan,
|
||||
Map<String, Object?>? route,
|
||||
Map<String, Object?>? music,
|
||||
Map<String, Object?>? announce,
|
||||
}) => _channel.invokeMethod<void>('start', {
|
||||
'path': path,
|
||||
'activity': activity.name,
|
||||
|
|
@ -51,6 +52,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
|||
'plan': plan == null ? null : jsonEncode(plan),
|
||||
'route': route == null ? null : jsonEncode(route),
|
||||
'music': music == null ? null : jsonEncode(music),
|
||||
'announce': announce == null ? null : jsonEncode(announce),
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// The activities whose recordings announce their kilometres (ADR 0030,
|
||||
/// D49), remembered in a file of the person's folder as the Record sheet's
|
||||
/// switch last was for each; none at first. Never synced, as the strap
|
||||
/// isn't.
|
||||
class AnnounceStore(final File _file) {
|
||||
Set<Activity> read() {
|
||||
if (!_file.existsSync()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return switch (jsonDecode(_file.readAsStringSync())) {
|
||||
{'activities': final List<Object?> names} => {
|
||||
for (final name in names) ?Activity.values.asNameMap()[name],
|
||||
},
|
||||
_ => {},
|
||||
};
|
||||
} on FormatException {
|
||||
log('The announcements not read', name: 'Recording');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
void save(Set<Activity> activities) => _file.writeAsStringSync(
|
||||
jsonEncode({
|
||||
'activities': [for (final activity in activities) activity.name],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -114,6 +114,10 @@ abstract interface class RecorderPlatform() {
|
|||
/// What it plays, as the recorder takes it (D47, D48,
|
||||
/// `recorderMusicOf`).
|
||||
Map<String, Object?>? music,
|
||||
|
||||
/// The words each distance passed is announced in (D49,
|
||||
/// `recorderAnnouncementsOf`); null: none is.
|
||||
Map<String, Object?>? announce,
|
||||
});
|
||||
|
||||
/// Pauses, or goes on, by hand.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// How many distances are spelled out ahead: 100 km on foot, 500 km by
|
||||
/// bike; further on, the recorder says the kilometres as digits.
|
||||
const _distancesSpelled = 100;
|
||||
|
||||
/// Every how many metres a recording of [activity] is announced (ADR 0030,
|
||||
/// D49): every kilometre on foot, every 5 km by bike, never on a rower.
|
||||
int? announceEvery(Activity activity) => switch (activity) {
|
||||
Activity.running || Activity.walking || Activity.hiking => 1000,
|
||||
Activity.cycling => 5000,
|
||||
Activity.indoorRowing || Activity.other => null,
|
||||
};
|
||||
|
||||
/// The announcements' words for [activity] as the recorder takes them, in
|
||||
/// the app's language [locale] (a BCP 47 tag): `every`, the metres between
|
||||
/// them; `distances`, what is said for the first, the second and on;
|
||||
/// `hours`, `minutes` and `seconds`, what each count of them is said as,
|
||||
/// which the recorder puts a time and a pace together from; `time`, `pace`
|
||||
/// on foot or `speed` by bike (a number the recorder writes in [locale]),
|
||||
/// and `heartRate`, each with its placeholder. Null where nothing is
|
||||
/// announced.
|
||||
Map<String, Object?>? recorderAnnouncementsOf(
|
||||
AppLocalizations l10n,
|
||||
String locale,
|
||||
Activity activity,
|
||||
) {
|
||||
final every = announceEvery(activity);
|
||||
if (every == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
'every': every,
|
||||
'locale': locale,
|
||||
'distances': [
|
||||
for (var n = 1; n <= _distancesSpelled; n++)
|
||||
l10n.announceKilometres(n * every ~/ 1000),
|
||||
],
|
||||
'hours': [for (var n = 0; n < 24; n++) l10n.announceHours(n)],
|
||||
'minutes': [for (var n = 0; n < 60; n++) l10n.planSayMinutes(n)],
|
||||
'seconds': [for (var n = 0; n < 60; n++) l10n.planSaySeconds(n)],
|
||||
'time': l10n.announceTime('{time}'),
|
||||
if (activity == Activity.cycling)
|
||||
'speed': l10n.announceSpeed('{speed}')
|
||||
else
|
||||
'pace': l10n.announcePace('{pace}'),
|
||||
'heartRate': l10n.announceHeartRate('{bpm}'),
|
||||
};
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ 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/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';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
|
||||
|
|
@ -44,7 +45,8 @@ bool autoPausesByDefault(Activity activity) =>
|
|||
/// heart rate (D41), for a row the rower, without which it doesn't start
|
||||
/// (D28), the interval plan to run, none by default (D42), and the route
|
||||
/// to follow, none by default, for an activity with GPS (D45), and on foot
|
||||
/// what it plays, as last chosen (D48); true once
|
||||
/// what it plays, as last chosen (D48), and whether it announces each
|
||||
/// distance, as last switched for the activity (D49); true once
|
||||
/// Start was pressed, the start asked of [bloc].
|
||||
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
|
||||
await showAdaptiveSheet<bool>(
|
||||
|
|
@ -83,6 +85,11 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
null => MusicChoice.off,
|
||||
};
|
||||
|
||||
/// The activities announced (D49), as the person last switched each.
|
||||
late var _announced = PersonScope.of<TrainPerson>(
|
||||
context,
|
||||
).recording!.announces.read();
|
||||
|
||||
/// [activity] chosen, with its auto-pause's default.
|
||||
void _choose(Activity activity) {
|
||||
_activity = activity;
|
||||
|
|
@ -121,6 +128,27 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
: l10n.recordAutoPauseHint,
|
||||
),
|
||||
),
|
||||
if (announceEvery(_activity) != null)
|
||||
SwitchListTile(
|
||||
value: _announced.contains(_activity),
|
||||
onChanged: (value) {
|
||||
setState(
|
||||
() => _announced = {
|
||||
..._announced.where((activity) => activity != _activity),
|
||||
if (value) _activity,
|
||||
},
|
||||
);
|
||||
PersonScope.of<TrainPerson>(
|
||||
context,
|
||||
).recording!.announces.save(_announced);
|
||||
},
|
||||
title: Text(l10n.recordAnnounce),
|
||||
subtitle: Text(
|
||||
_activity == Activity.cycling
|
||||
? l10n.recordAnnounceBike
|
||||
: l10n.recordAnnounceFoot,
|
||||
),
|
||||
),
|
||||
if (_activity == Activity.indoorRowing)
|
||||
BlocBuilder<RecordingBloc, RecordingState>(
|
||||
bloc: widget.bloc,
|
||||
|
|
@ -225,6 +253,9 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
|||
plan: _plan,
|
||||
route: _activity == Activity.indoorRowing ? null : _route,
|
||||
music: playsMusic(_activity) ? _music : MusicChoice.off,
|
||||
announce:
|
||||
announceEvery(_activity) != null &&
|
||||
_announced.contains(_activity),
|
||||
),
|
||||
);
|
||||
PersonScope.of<TrainPerson>(
|
||||
|
|
|
|||
Loading…
Reference in a new issue