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:
Tizian 「ティツィアーン」 2026-10-29 14:25:13 +01:00
commit db7e0b7bcd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 782 additions and 11 deletions

View file

@ -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}"
}

View file

@ -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"}}}
}

View file

@ -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

View file

@ -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';
}
}

View file

@ -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`).

View file

@ -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(),

View file

@ -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,

View file

@ -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.

View file

@ -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

View file

@ -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],
}),
);
}

View file

@ -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.

View file

@ -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}'),
};
}

View file

@ -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>(