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
24 changed files with 782 additions and 11 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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(" ")
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,8 @@ import java.util.Locale
|
|||
* 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 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.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/** A distance passed (D49): spoken without a tone, after a cue still speaking. */
|
||||
fun announce(say: String) = speak(say, TextToSpeech.QUEUE_ADD)
|
||||
|
||||
fun close() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
speech.stop()
|
||||
|
|
@ -108,10 +112,10 @@ class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
|
|||
audio.abandonAudioFocusRequest(focus)
|
||||
}
|
||||
|
||||
private fun speak(text: String) {
|
||||
private fun speak(text: String, queue: Int = TextToSpeech.QUEUE_FLUSH) {
|
||||
if (!speaks || text.isBlank()) return
|
||||
audio.requestAudioFocus(focus)
|
||||
speech.speak(text, TextToSpeech.QUEUE_FLUSH, null, "cue")
|
||||
speech.speak(text, queue, null, "cue")
|
||||
}
|
||||
|
||||
private fun vibrate(pattern: LongArray) {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class MainActivity : FlutterActivity() {
|
|||
call.argument<String>("music")?.let { music ->
|
||||
File(noBackupFilesDir, "music.json").apply { writeText(music) }.path
|
||||
},
|
||||
announce = call.argument<String>("announce"),
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
private const val EXTRA_PLAN = "plan"
|
||||
private const val EXTRA_ROUTE = "route"
|
||||
private const val EXTRA_MUSIC = "music"
|
||||
private const val EXTRA_ANNOUNCE = "announce"
|
||||
private const val CHANNEL = "recording"
|
||||
private const val NOTIFICATION_ID = 5301
|
||||
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
|
||||
* [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 [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(
|
||||
context: Context,
|
||||
|
|
@ -96,6 +98,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
plan: String?,
|
||||
route: String?,
|
||||
music: String?,
|
||||
announce: String?,
|
||||
) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
|
|
@ -110,7 +113,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
.putExtra(EXTRA_LOOP, loop)
|
||||
.putExtra(EXTRA_PLAN, plan)
|
||||
.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 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. */
|
||||
private var guide: RouteGuide? = null
|
||||
private var guideWords: JSONObject? = null
|
||||
|
|
@ -188,6 +196,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
plan?.let { runner ->
|
||||
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.
|
||||
if (now - lastMusicAt >= 5_000) followCadence(now, anew = false)
|
||||
if (now - lastFlush >= FLUSH_EVERY_MS) {
|
||||
|
|
@ -310,6 +322,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
intent.getStringExtra(EXTRA_MUSIC)?.let { at ->
|
||||
runCatching { JSONObject(File(at).readText()) }.getOrNull()?.let { playMusic(it, now) }
|
||||
}
|
||||
intent.getStringExtra(EXTRA_ANNOUNCE)?.let { announce(JSONObject(it), now) }
|
||||
lastFlush = now
|
||||
handler.postDelayed(ticker, 1_000)
|
||||
}
|
||||
|
|
@ -366,6 +379,14 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
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. */
|
||||
private fun followCadence(now: Long, anew: Boolean) {
|
||||
val chooser = conductor ?: return
|
||||
|
|
@ -626,6 +647,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
|||
loopReading = null
|
||||
plan = null
|
||||
guide = null
|
||||
announcer = null
|
||||
cues?.close()
|
||||
cues = null
|
||||
music?.close()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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/model/profile.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/recorded_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
|
|
@ -1110,6 +1111,7 @@ void main() {
|
|||
file: file(),
|
||||
straps: DeviceStore(File('${folder.path}/strap.json')),
|
||||
rowers: DeviceStore(File('${folder.path}/rower.json')),
|
||||
announces: AnnounceStore(File('${folder.path}/announce.json')),
|
||||
),
|
||||
workouts: workouts,
|
||||
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 '
|
||||
'only held down and is saved', (tester) async {
|
||||
var now = at;
|
||||
|
|
@ -1451,6 +1498,8 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Running'));
|
||||
await tester.pump();
|
||||
await tester.ensureVisible(find.text('Route'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Route'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -2007,6 +2056,9 @@ void main() {
|
|||
file: File('${folder.path}/recording.txt'),
|
||||
straps: DeviceStore(File('${folder.path}/strap.json')),
|
||||
rowers: DeviceStore(File('${folder.path}/rower.json')),
|
||||
announces: AnnounceStore(
|
||||
File('${folder.path}/announce.json'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Routes'));
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue