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
|
* 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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
/// 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.
|
||||||
|
|
|
||||||
|
|
@ -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/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>(
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue