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
24 changed files with 782 additions and 11 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,111 @@
package app.halkyon.train
import java.text.NumberFormat
import java.util.Locale
import kotlin.math.roundToInt
import kotlin.math.roundToLong
import org.json.JSONObject
/** A distance passed (ADR 0030, D49): the [count]th, the moving time so far, the split's. */
data class Announcement(val count: Int, val movingMs: Long, val splitMs: Long, val splitMetres: Double)
/**
* Tells when a recording has gone another [every] metres, on the distance the plan's measures
* keep (paused fixes left out), and the moving time it took, a tick a second; a jump past several
* says the last of them; from the recording's [start] on.
*/
class Announcer(private val every: Int, start: Long) {
private var movingMs = 0L
private var lastTick = start
private var announced = 0
private var lastMovingMs = 0L
private var lastMetres = 0.0
fun tick(now: Long, paused: Boolean, metres: Double): Announcement? {
if (!paused) movingMs += now - lastTick
lastTick = now
val count = (metres / every).toInt()
if (count <= announced) return null
announced = count
val passed = count * every.toDouble()
val announcement =
Announcement(count, movingMs, splitMs = movingMs - lastMovingMs, splitMetres = passed - lastMetres)
lastMovingMs = movingMs
lastMetres = passed
return announcement
}
}
/**
* What is said of each [Announcement], in the app's language (D49, `recorderAnnouncementsOf`):
* the distances spelled out, the counts of [hours], [minutes] and [seconds] a time and a pace are
* put together from, and the sentences with their placeholders; [pace] on foot, [speed] by bike.
*/
data class AnnounceWords(
val every: Int,
val distances: List<String>,
val hours: List<String>,
val minutes: List<String>,
val seconds: List<String>,
val time: String,
val pace: String?,
val speed: String?,
val heartRate: String,
val locale: Locale?,
) {
companion object {
fun of(json: JSONObject): AnnounceWords {
fun strings(name: String): List<String> =
json.optJSONArray(name)?.let { array -> List(array.length()) { array.optString(it) } }.orEmpty()
return AnnounceWords(
every = json.optInt("every", 1000),
distances = strings("distances"),
hours = strings("hours"),
minutes = strings("minutes"),
seconds = strings("seconds"),
time = json.optString("time"),
pace = json.optString("pace").takeIf { it.isNotEmpty() },
speed = json.optString("speed").takeIf { it.isNotEmpty() },
heartRate = json.optString("heartRate"),
locale = json.optString("locale").takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag),
)
}
}
/** The line said for [announcement], with the heart rate [bpm] where one came lately. */
fun say(announcement: Announcement, bpm: Int?): String {
val distance =
distances.getOrNull(announcement.count - 1) ?: "${announcement.count * every / 1000} km"
val parts = mutableListOf(distance, time.replace("{time}", spoken(announcement.movingMs)))
if (announcement.splitMs > 0 && announcement.splitMetres > 0) {
pace?.let {
val perKm = (announcement.splitMs * 1000 / announcement.splitMetres).roundToLong()
parts += it.replace("{pace}", spoken(perKm))
}
speed?.let {
val kmh = announcement.splitMetres / 1000 / (announcement.splitMs / 3_600_000.0)
val format =
NumberFormat.getNumberInstance(locale ?: Locale.getDefault()).apply {
minimumFractionDigits = 1
maximumFractionDigits = 1
}
parts += it.replace("{speed}", format.format(kmh))
}
}
bpm?.let { parts += heartRate.replace("{bpm}", it.toString()) }
return parts.joinToString(". ", postfix = ".")
}
/** [ms] as hours and minutes, or as minutes and seconds within the first hour; nones left out. */
private fun spoken(ms: Long): String {
val total = (ms / 1000.0).roundToInt()
val (h, m, s) = Triple(total / 3600, total / 60 % 60, total % 60)
val parts =
if (h > 0) {
listOfNotNull(hours.getOrNull(h) ?: "$h h", minutes.getOrNull(m)?.takeIf { m > 0 })
} else {
listOfNotNull(minutes.getOrNull(m)?.takeIf { m > 0 }, seconds.getOrNull(s)?.takeIf { s > 0 || m == 0 })
}
return parts.joinToString(" ")
}
}

View file

@ -20,7 +20,8 @@ import java.util.Locale
* step changes, a short tone for each of a timed step's last seconds, a * step changes, a short tone for each of a timed step's last seconds, a
* double vibration off target; a route's (D45), a tone before a turn, a * double vibration off target; a route's (D45), a tone before a turn, a
* double vibration off the route, a tone back on it; and a spoken line where * double vibration off the route, a tone back on it; and a spoken line where
* the phone has a text-to-speech engine (GrapheneOS ships none; RHVoice or * the phone has a text-to-speech engine, the distance's announcement (D49)
* after whatever is being said (GrapheneOS ships none; RHVoice or
* eSpeak give one), other sound ducked while it speaks. * eSpeak give one), other sound ducked while it speaks.
*/ */
class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener { class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
@ -100,6 +101,9 @@ class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
handler.postDelayed({ speak(say) }, 400) handler.postDelayed({ speak(say) }, 400)
} }
/** A distance passed (D49): spoken without a tone, after a cue still speaking. */
fun announce(say: String) = speak(say, TextToSpeech.QUEUE_ADD)
fun close() { fun close() {
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
speech.stop() speech.stop()
@ -108,10 +112,10 @@ class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
audio.abandonAudioFocusRequest(focus) audio.abandonAudioFocusRequest(focus)
} }
private fun speak(text: String) { private fun speak(text: String, queue: Int = TextToSpeech.QUEUE_FLUSH) {
if (!speaks || text.isBlank()) return if (!speaks || text.isBlank()) return
audio.requestAudioFocus(focus) audio.requestAudioFocus(focus)
speech.speak(text, TextToSpeech.QUEUE_FLUSH, null, "cue") speech.speak(text, queue, null, "cue")
} }
private fun vibrate(pattern: LongArray) { private fun vibrate(pattern: LongArray) {

View file

@ -85,6 +85,7 @@ class MainActivity : FlutterActivity() {
call.argument<String>("music")?.let { music -> call.argument<String>("music")?.let { music ->
File(noBackupFilesDir, "music.json").apply { writeText(music) }.path File(noBackupFilesDir, "music.json").apply { writeText(music) }.path
}, },
announce = call.argument<String>("announce"),
) )
result.success(null) result.success(null)
} }

View file

@ -63,6 +63,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_PLAN = "plan" private const val EXTRA_PLAN = "plan"
private const val EXTRA_ROUTE = "route" private const val EXTRA_ROUTE = "route"
private const val EXTRA_MUSIC = "music" private const val EXTRA_MUSIC = "music"
private const val EXTRA_ANNOUNCE = "announce"
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
@ -82,7 +83,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
* at [rower] and the Loop [loop] (null: none), its notification as * at [rower] and the Loop [loop] (null: none), its notification as
* [notice] says, running [plan] (JSON) and following the route in the * [notice] says, running [plan] (JSON) and following the route in the
* file at [route] (D45; null: none of either), playing the music the * file at [route] (D45; null: none of either), playing the music the
* file at [music] says (D47, D48; null: none). * file at [music] says (D47, D48; null: none), announcing each distance
* passed in the words of [announce] (JSON, D49; null: never).
*/ */
fun start( fun start(
context: Context, context: Context,
@ -96,6 +98,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
plan: String?, plan: String?,
route: String?, route: String?,
music: String?, music: String?,
announce: String?,
) { ) {
ContextCompat.startForegroundService( ContextCompat.startForegroundService(
context, context,
@ -110,7 +113,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_LOOP, loop) .putExtra(EXTRA_LOOP, loop)
.putExtra(EXTRA_PLAN, plan) .putExtra(EXTRA_PLAN, plan)
.putExtra(EXTRA_ROUTE, route) .putExtra(EXTRA_ROUTE, route)
.putExtra(EXTRA_MUSIC, music), .putExtra(EXTRA_MUSIC, music)
.putExtra(EXTRA_ANNOUNCE, announce),
) )
} }
@ -170,6 +174,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var usualCadence = 165 private var usualCadence = 165
private var lastMusicAt = 0L private var lastMusicAt = 0L
/** What tells each distance passed (D49), and its words. */
private var announcer: Announcer? = null
private var announceWords: AnnounceWords? = null
/** The route it follows (D45) and its words. */ /** The route it follows (D45) and its words. */
private var guide: RouteGuide? = null private var guide: RouteGuide? = null
private var guideWords: JSONObject? = null private var guideWords: JSONObject? = null
@ -188,6 +196,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
plan?.let { runner -> plan?.let { runner ->
cue(runner.tick(now, isPaused(), measures.metres) { kind -> measures.measure(kind, now) }, now) cue(runner.tick(now, isPaused(), measures.metres) { kind -> measures.measure(kind, now) }, now)
} }
announcer?.tick(now, isPaused(), measures.metres)?.let { passed ->
val bpm = measures.measure("heartRate", now)?.toInt()
announceWords?.let { words -> cues?.announce(words.say(passed, bpm)) }
}
// The measured cadence moves the music every 5 s at most. // The measured cadence moves the music every 5 s at most.
if (now - lastMusicAt >= 5_000) followCadence(now, anew = false) if (now - lastMusicAt >= 5_000) followCadence(now, anew = false)
if (now - lastFlush >= FLUSH_EVERY_MS) { if (now - lastFlush >= FLUSH_EVERY_MS) {
@ -310,6 +322,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
intent.getStringExtra(EXTRA_MUSIC)?.let { at -> intent.getStringExtra(EXTRA_MUSIC)?.let { at ->
runCatching { JSONObject(File(at).readText()) }.getOrNull()?.let { playMusic(it, now) } runCatching { JSONObject(File(at).readText()) }.getOrNull()?.let { playMusic(it, now) }
} }
intent.getStringExtra(EXTRA_ANNOUNCE)?.let { announce(JSONObject(it), now) }
lastFlush = now lastFlush = now
handler.postDelayed(ticker, 1_000) handler.postDelayed(ticker, 1_000)
} }
@ -366,6 +379,14 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
lastMusicAt = now lastMusicAt = now
} }
/** Announces each distance passed in the words [json] holds (D49, `recorderAnnouncementsOf`). */
private fun announce(json: JSONObject, now: Long) {
val words = AnnounceWords.of(json)
announceWords = words
cuesIn(json.optString("locale"))
announcer = Announcer(words.every, start = now)
}
/** The cadence the music follows now: the step's, else the steps', else the usual one. */ /** The cadence the music follows now: the step's, else the steps', else the usual one. */
private fun followCadence(now: Long, anew: Boolean) { private fun followCadence(now: Long, anew: Boolean) {
val chooser = conductor ?: return val chooser = conductor ?: return
@ -626,6 +647,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
loopReading = null loopReading = null
plan = null plan = null
guide = null guide = null
announcer = null
cues?.close() cues?.close()
cues = null cues = null
music?.close() music?.close()

View file

@ -0,0 +1,108 @@
package app.halkyon.train
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The announcements every km on foot, every 5 km by bike (ADR 0030, D49). */
class AnnouncerTest {
private fun counts(word: String, plural: String, size: Int) =
List(size) { if (it == 1) "1 $word" else "$it $plural" }
private val run =
AnnounceWords(
every = 1000,
distances = List(100) { if (it == 0) "1 kilometre" else "${it + 1} kilometres" },
hours = counts("hour", "hours", 24),
minutes = counts("minute", "minutes", 60),
seconds = counts("second", "seconds", 60),
time = "Time {time}",
pace = "Pace {pace} a kilometre",
speed = null,
heartRate = "Heart rate {bpm}",
locale = Locale.UK,
)
@Test
fun `each km passed is announced once, with the moving time and the split`() {
val announcer = Announcer(1000, start = 0)
var now = 0L
var metres = 0.0
val heard = mutableListOf<Announcement>()
// 4 m/s for 600 s: 2400 m, a tick a second.
repeat(600) {
now += 1000
metres += 4
announcer.tick(now, paused = false, metres = metres)?.let(heard::add)
}
assertEquals(listOf(1, 2), heard.map { it.count })
assertEquals(250_000L, heard[0].movingMs)
assertEquals(250_000L, heard[0].splitMs)
assertEquals(500_000L, heard[1].movingMs)
assertEquals(250_000L, heard[1].splitMs)
assertEquals(1000.0, heard[1].splitMetres, 1e-9)
}
@Test
fun `a pause's time counts neither in the moving time nor the split`() {
val announcer = Announcer(1000, start = 0)
var now = 0L
var metres = 0.0
repeat(200) {
now += 1000
metres += 2.5
assertNull(announcer.tick(now, paused = false, metres = metres))
}
repeat(60) {
now += 1000
assertNull(announcer.tick(now, paused = true, metres = metres))
}
var heard: Announcement? = null
while (heard == null) {
now += 1000
metres += 2.5
heard = announcer.tick(now, paused = false, metres = metres)
}
assertEquals(400_000L, heard.movingMs)
}
@Test
fun `a jump of several km says the last of them`() {
val announcer = Announcer(1000, start = 0)
assertNull(announcer.tick(1000, paused = false, metres = 10.0))
assertEquals(3, announcer.tick(2000, paused = false, metres = 3100.0)?.count)
assertNull(announcer.tick(3000, paused = false, metres = 3200.0))
}
@Test
fun `a run's line says the distance, the time, the split's pace and the heart rate`() {
val line = run.say(Announcement(count = 2, movingMs = 625_000, splitMs = 312_000, splitMetres = 1000.0), bpm = 152)
assertEquals(
"2 kilometres. Time 10 minutes 25 seconds. Pace 5 minutes 12 seconds a kilometre. Heart rate 152.",
line,
)
}
@Test
fun `parts that are none are left out, seconds too after the first hour, the heart rate where none came`() {
val line = run.say(Announcement(count = 1, movingMs = 3_725_000, splitMs = 300_000, splitMetres = 1000.0), bpm = null)
assertEquals("1 kilometre. Time 1 hour 2 minutes. Pace 5 minutes a kilometre.", line)
}
@Test
fun `a ride says its speed in the app's language, and a distance past the spelled ones as digits`() {
val ride =
run.copy(
every = 5000,
distances = listOf("5 Kilometer"),
pace = null,
speed = "Tempo {speed} Kilometer pro Stunde",
locale = Locale.GERMANY,
)
val line = ride.say(Announcement(count = 1, movingMs = 600_000, splitMs = 600_000, splitMetres = 5000.0), bpm = null)
assertEquals("5 Kilometer. Time 10 minutes. Tempo 30,0 Kilometer pro Stunde.", line)
val far = ride.say(Announcement(count = 2, movingMs = 1_200_000, splitMs = 600_000, splitMetres = 5000.0), bpm = null)
assertEquals("10 km. Time 20 minutes. Tempo 30,0 Kilometer pro Stunde.", far)
}
}

View file

@ -505,5 +505,14 @@
"recordMusic": "Musik", "recordMusic": "Musik",
"recordMusicOn": "Musik", "recordMusicOn": "Musik",
"recordMetronome": "Metronom", "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": "Music",
"@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."}, "@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."},
"recordMetronome": "Metronome", "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: /// In en, this message translates to:
/// **'Off'** /// **'Off'**
String get recordMusicOff; 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 class _AppLocalizationsDelegate

View file

@ -1821,4 +1821,55 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get recordMusicOff => 'Aus'; 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 @override
String get recordMusicOff => 'Off'; 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`). /// 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/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.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/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/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/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.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); return music == null ? null : (music: music, cadence: cadence);
}, },
announcementsOf: (activity) => recorderAnnouncementsOf(
AppLocalizations.of(context),
Localizations.localeOf(context).toLanguageTag(),
activity,
),
routeOf: (route, activity) => recorderRouteOf( routeOf: (route, activity) => recorderRouteOf(
AppLocalizations.of(context), AppLocalizations.of(context),
Localizations.localeOf(context).toLanguageTag(), 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/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gateway.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/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/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/route/data/route_files.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 /// Where a device records workouts (ADR 0030, D13, D18, D27, D28): its
/// recorder, and the person's recording file, strap and rower in their /// recorder, and the person's recording file, strap and rower in their
/// folder. /// folder, and the activities they have announced (D49).
typedef RecordingDevice = ({ typedef RecordingDevice = ({
RecorderPlatform platform, RecorderPlatform platform,
File file, File file,
DeviceStore straps, DeviceStore straps,
DeviceStore rowers, DeviceStore rowers,
AnnounceStore announces,
}); });
/// A signed-in person's dependencies: who they are and their workouts, on /// 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')), file: File(p.join(folder.path, 'recording.txt')),
straps: DeviceStore(File(p.join(folder.path, 'strap.json'))), straps: DeviceStore(File(p.join(folder.path, 'strap.json'))),
rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))), rowers: DeviceStore(File(p.join(folder.path, 'rower.json'))),
announces: AnnounceStore(
File(p.join(folder.path, 'announce.json')),
),
) )
: null, : null,
health: health, health: health,

View file

@ -72,6 +72,11 @@ class RecordingBloc(
/// recorded, in the app's language. /// recorded, in the app's language.
final Map<String, Object?> Function(RouteContent route, Activity activity)? final Map<String, Object?> Function(RouteContent route, Activity activity)?
routeOf, 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> { }) extends Bloc<RecordingEvent, RecordingState> {
this : super(const RecordingState()) { this : super(const RecordingState()) {
on<RecordingEventOpened>(_onOpened); on<RecordingEventOpened>(_onOpened);
@ -237,6 +242,7 @@ class RecordingBloc(
plan: plan, plan: plan,
route: route, route: route,
music: music?.music, music: music?.music,
announce: event.announce ? announcementsOf?.call(activity) : null,
); );
_shown = _keyOf(notice); _shown = _keyOf(notice);
_follow(); _follow();
@ -496,7 +502,8 @@ final class const RecordingEventOpened() extends RecordingEvent;
/// The person starts recording [activity], pausing by itself where /// The person starts recording [activity], pausing by itself where
/// [autoPause] (D16), with their strap unless not [useStrap] (D27), running /// [autoPause] (D16), with their strap unless not [useStrap] (D27), running
/// [plan] where one was chosen (D42), following [route] where one was /// [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 class const RecordingEventStartRequested(
final Activity activity, { final Activity activity, {
final bool autoPause = true, final bool autoPause = true,
@ -504,6 +511,7 @@ final class const RecordingEventStartRequested(
final PlanContent? plan, final PlanContent? plan,
final RouteContent? route, final RouteContent? route,
final MusicChoice music = MusicChoice.off, final MusicChoice music = MusicChoice.off,
final bool announce = false,
}) extends RecordingEvent; }) extends RecordingEvent;
/// The person chose [device] as their [kind] on this device. /// 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?>? plan,
Map<String, Object?>? route, Map<String, Object?>? route,
Map<String, Object?>? music, Map<String, Object?>? music,
Map<String, Object?>? announce,
}) => _channel.invokeMethod<void>('start', { }) => _channel.invokeMethod<void>('start', {
'path': path, 'path': path,
'activity': activity.name, 'activity': activity.name,
@ -51,6 +52,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
'plan': plan == null ? null : jsonEncode(plan), 'plan': plan == null ? null : jsonEncode(plan),
'route': route == null ? null : jsonEncode(route), 'route': route == null ? null : jsonEncode(route),
'music': music == null ? null : jsonEncode(music), 'music': music == null ? null : jsonEncode(music),
'announce': announce == null ? null : jsonEncode(announce),
}); });
@override @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, /// What it plays, as the recorder takes it (D47, D48,
/// `recorderMusicOf`). /// `recorderMusicOf`).
Map<String, Object?>? music, 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. /// 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/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/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/device_sheet.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/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/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 /// 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 /// (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 /// 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]. /// Start was pressed, the start asked of [bloc].
Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async => Future<bool> showRecordSheet(BuildContext context, RecordingBloc bloc) async =>
await showAdaptiveSheet<bool>( await showAdaptiveSheet<bool>(
@ -83,6 +85,11 @@ class _RecordSheetState() extends State<_RecordSheet> {
null => MusicChoice.off, 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. /// [activity] chosen, with its auto-pause's default.
void _choose(Activity activity) { void _choose(Activity activity) {
_activity = activity; _activity = activity;
@ -121,6 +128,27 @@ class _RecordSheetState() extends State<_RecordSheet> {
: l10n.recordAutoPauseHint, : 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) if (_activity == Activity.indoorRowing)
BlocBuilder<RecordingBloc, RecordingState>( BlocBuilder<RecordingBloc, RecordingState>(
bloc: widget.bloc, bloc: widget.bloc,
@ -225,6 +253,9 @@ class _RecordSheetState() extends State<_RecordSheet> {
plan: _plan, plan: _plan,
route: _activity == Activity.indoorRowing ? null : _route, route: _activity == Activity.indoorRowing ? null : _route,
music: playsMusic(_activity) ? _music : MusicChoice.off, music: playsMusic(_activity) ? _music : MusicChoice.off,
announce:
announceEvery(_activity) != null &&
_announced.contains(_activity),
), ),
); );
PersonScope.of<TrainPerson>( PersonScope.of<TrainPerson>(

View file

@ -28,6 +28,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart'; import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart'; import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart'; import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.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/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
@ -1110,6 +1111,7 @@ void main() {
file: file(), file: file(),
straps: DeviceStore(File('${folder.path}/strap.json')), straps: DeviceStore(File('${folder.path}/strap.json')),
rowers: DeviceStore(File('${folder.path}/rower.json')), rowers: DeviceStore(File('${folder.path}/rower.json')),
announces: AnnounceStore(File('${folder.path}/announce.json')),
), ),
workouts: workouts, workouts: workouts,
loop: loop, loop: loop,
@ -1165,6 +1167,51 @@ void main() {
}); });
}); });
testWidgets('announcements are switched for each activity and '
'remembered, a row offered none (D49)', (tester) async {
await withClock(Clock(() => at), () async {
await startRecording(tester);
await tester.tap(find.text('Record'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cycling'));
await tester.pump();
expect(
find.text('Every 5 km: time, speed and heart rate'),
findsOneWidget,
);
await tester.tap(find.text('Announcements'));
await tester.pump();
await tester.tap(find.text('Running'));
await tester.pump();
expect(
find.text('Every kilometre: time, pace and heart rate'),
findsOneWidget,
);
expect(
tester
.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Announcements'),
)
.value,
isFalse,
);
await tester.tap(find.text('Rowing indoors'));
await tester.pump();
expect(find.text('Announcements'), findsNothing);
await tester.tap(find.text('Cycling'));
await tester.pump();
await tester.tap(find.text('Start'));
await tester.runAsync(pumpEventQueue);
await pass(tester, 2);
expect(platform.started.single.announce?['every'], 5000);
expect(
AnnounceStore(File('${folder.path}/announce.json')).read(),
{Activity.cycling},
);
});
});
testWidgets('a ride is chosen, waits for GPS, shows its distance, stops ' testWidgets('a ride is chosen, waits for GPS, shows its distance, stops '
'only held down and is saved', (tester) async { 'only held down and is saved', (tester) async {
var now = at; var now = at;
@ -1451,6 +1498,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Running')); await tester.tap(find.text('Running'));
await tester.pump(); await tester.pump();
await tester.ensureVisible(find.text('Route'));
await tester.pump();
await tester.tap(find.text('Route')); await tester.tap(find.text('Route'));
await tester.runAsync(pumpEventQueue); await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -2007,6 +2056,9 @@ void main() {
file: File('${folder.path}/recording.txt'), file: File('${folder.path}/recording.txt'),
straps: DeviceStore(File('${folder.path}/strap.json')), straps: DeviceStore(File('${folder.path}/strap.json')),
rowers: DeviceStore(File('${folder.path}/rower.json')), rowers: DeviceStore(File('${folder.path}/rower.json')),
announces: AnnounceStore(
File('${folder.path}/announce.json'),
),
), ),
); );
await tester.tap(find.text('Routes')); await tester.tap(find.text('Routes'));

Some files were not shown because too many files have changed in this diff Show more