feat(train): a recording follows a route, its turns told (ADR 0030, D45)
The Record sheet offers the routes with a way planned; the recorder's service follows the one chosen offline (RouteGuide.kt): turns told 50 m before them (100 m on a bike) after a tone, off the route past 40 m for 10 s a cue with the way back, told again on rejoining, the end told. The recording file keeps the route and the guidance; the recording screen shows the next turn or the way back, and swipes to a map of the route, the track and the position, north or the heading up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
aea99c68ca
commit
b0c0eeb3d5
29 changed files with 2153 additions and 171 deletions
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,120 @@
|
||||||
|
package app.halkyon.train
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.ToneGenerator
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recording's cues: a plan's (ADR 0030, D42), a tone and a vibration as 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 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
|
||||||
|
* eSpeak give one), other sound ducked while it speaks.
|
||||||
|
*/
|
||||||
|
class Cues(context: Context, locale: String?) : TextToSpeech.OnInitListener {
|
||||||
|
private val audio = context.getSystemService(AudioManager::class.java)
|
||||||
|
private val tones = runCatching { ToneGenerator(AudioManager.STREAM_MUSIC, 90) }.getOrNull()
|
||||||
|
private val vibrator: Vibrator? =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Vibrator::class.java)
|
||||||
|
}
|
||||||
|
private val language = locale?.let(Locale::forLanguageTag)
|
||||||
|
private val speech = TextToSpeech(context.applicationContext, this)
|
||||||
|
private var speaks = false
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val attributes =
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build()
|
||||||
|
private val focus =
|
||||||
|
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK).setAudioAttributes(attributes).build()
|
||||||
|
|
||||||
|
override fun onInit(status: Int) {
|
||||||
|
if (status != TextToSpeech.SUCCESS) return
|
||||||
|
val available = language?.let { speech.isLanguageAvailable(it) >= TextToSpeech.LANG_AVAILABLE } ?: true
|
||||||
|
if (language != null && available) speech.language = language
|
||||||
|
speech.setAudioAttributes(attributes)
|
||||||
|
speech.setOnUtteranceProgressListener(
|
||||||
|
object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) {}
|
||||||
|
|
||||||
|
override fun onDone(utteranceId: String?) {
|
||||||
|
audio.abandonAudioFocusRequest(focus)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onError(utteranceId: String?) {
|
||||||
|
audio.abandonAudioFocusRequest(focus)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
speaks = available
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A step changed, or the plan is done; [say] spoken. */
|
||||||
|
fun change(say: String) {
|
||||||
|
tones?.startTone(ToneGenerator.TONE_PROP_BEEP2, 400)
|
||||||
|
vibrate(longArrayOf(0, 400))
|
||||||
|
speak(say)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun countdown() {
|
||||||
|
tones?.startTone(ToneGenerator.TONE_PROP_BEEP, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun offTarget(say: String) {
|
||||||
|
vibrate(longArrayOf(0, 150, 120, 150))
|
||||||
|
speak(say)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A turn of the route ahead. */
|
||||||
|
fun turn(say: String) {
|
||||||
|
tones?.startTone(ToneGenerator.TONE_PROP_ACK, 300)
|
||||||
|
// Spoken once the tone has sounded.
|
||||||
|
handler.postDelayed({ speak(say) }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun offRoute(say: String) {
|
||||||
|
vibrate(longArrayOf(0, 150, 120, 150))
|
||||||
|
speak(say)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rejoined(say: String) {
|
||||||
|
tones?.startTone(ToneGenerator.TONE_PROP_ACK, 300)
|
||||||
|
handler.postDelayed({ speak(say) }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
speech.stop()
|
||||||
|
speech.shutdown()
|
||||||
|
tones?.release()
|
||||||
|
audio.abandonAudioFocusRequest(focus)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun speak(text: String) {
|
||||||
|
if (!speaks || text.isBlank()) return
|
||||||
|
audio.requestAudioFocus(focus)
|
||||||
|
speech.speak(text, TextToSpeech.QUEUE_FLUSH, null, "cue")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrate(pattern: LongArray) {
|
||||||
|
vibrator?.takeIf { it.hasVibrator() }?.vibrate(VibrationEffect.createWaveform(pattern, -1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
/** The app's question for permissions, answered once Android has asked. */
|
/** The app's question for permissions, answered once Android has asked. */
|
||||||
|
|
@ -64,6 +65,12 @@ class MainActivity : FlutterActivity() {
|
||||||
rower = call.argument<String>("rower"),
|
rower = call.argument<String>("rower"),
|
||||||
loop = call.argument<String>("loop"),
|
loop = call.argument<String>("loop"),
|
||||||
plan = call.argument<String>("plan"),
|
plan = call.argument<String>("plan"),
|
||||||
|
// In a file: a route's thousands of points are more than an
|
||||||
|
// intent carries (D45).
|
||||||
|
route =
|
||||||
|
call.argument<String>("route")?.let { route ->
|
||||||
|
File(noBackupFilesDir, "route.json").apply { writeText(route) }.path
|
||||||
|
},
|
||||||
)
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
private const val EXTRA_ROWER = "rower"
|
private const val EXTRA_ROWER = "rower"
|
||||||
private const val EXTRA_LOOP = "loop"
|
private const val EXTRA_LOOP = "loop"
|
||||||
private const val EXTRA_PLAN = "plan"
|
private const val EXTRA_PLAN = "plan"
|
||||||
|
private const val EXTRA_ROUTE = "route"
|
||||||
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
|
||||||
|
|
@ -78,7 +79,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
* Starts recording [activity] into the file at [path], pausing by itself
|
* Starts recording [activity] into the file at [path], pausing by itself
|
||||||
* by [autoPause] (null: never), with the strap at [strap], the rower
|
* by [autoPause] (null: never), with the strap at [strap], the rower
|
||||||
* at [rower] and the Loop [loop] (null: none), its notification as
|
* at [rower] and the Loop [loop] (null: none), its notification as
|
||||||
* [notice] says.
|
* [notice] says, running [plan] (JSON) and following the route in the
|
||||||
|
* file at [route] (D45; null: none of either).
|
||||||
*/
|
*/
|
||||||
fun start(
|
fun start(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -90,6 +92,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
rower: String?,
|
rower: String?,
|
||||||
loop: String?,
|
loop: String?,
|
||||||
plan: String?,
|
plan: String?,
|
||||||
|
route: String?,
|
||||||
) {
|
) {
|
||||||
ContextCompat.startForegroundService(
|
ContextCompat.startForegroundService(
|
||||||
context,
|
context,
|
||||||
|
|
@ -102,7 +105,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
.putExtra(EXTRA_STRAP, strap)
|
.putExtra(EXTRA_STRAP, strap)
|
||||||
.putExtra(EXTRA_ROWER, rower)
|
.putExtra(EXTRA_ROWER, rower)
|
||||||
.putExtra(EXTRA_LOOP, loop)
|
.putExtra(EXTRA_LOOP, loop)
|
||||||
.putExtra(EXTRA_PLAN, plan),
|
.putExtra(EXTRA_PLAN, plan)
|
||||||
|
.putExtra(EXTRA_ROUTE, route),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,9 +152,13 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
private var plan: PlanRunner? = null
|
private var plan: PlanRunner? = null
|
||||||
private var planSteps: List<RunStep> = emptyList()
|
private var planSteps: List<RunStep> = emptyList()
|
||||||
private var planWords: JSONObject? = null
|
private var planWords: JSONObject? = null
|
||||||
private var cues: PlanCues? = null
|
private var cues: Cues? = null
|
||||||
private val measures = PlanMeasures()
|
private val measures = PlanMeasures()
|
||||||
|
|
||||||
|
/** The route it follows (D45) and its words. */
|
||||||
|
private var guide: RouteGuide? = null
|
||||||
|
private var guideWords: JSONObject? = null
|
||||||
|
|
||||||
/** The rower's strokes and metres, from its first frame on (D28). */
|
/** The rower's strokes and metres, from its first frame on (D28). */
|
||||||
private val strokes = Counted()
|
private val strokes = Counted()
|
||||||
private val metres = Counted()
|
private val metres = Counted()
|
||||||
|
|
@ -276,6 +284,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
}
|
}
|
||||||
loopAt?.let(::readLoop)
|
loopAt?.let(::readLoop)
|
||||||
intent.getStringExtra(EXTRA_PLAN)?.let { runPlan(JSONObject(it), now) }
|
intent.getStringExtra(EXTRA_PLAN)?.let { runPlan(JSONObject(it), now) }
|
||||||
|
intent.getStringExtra(EXTRA_ROUTE)?.let { at ->
|
||||||
|
runCatching { JSONObject(File(at).readText()) }.getOrNull()?.let { followRoute(it, now) }
|
||||||
|
}
|
||||||
lastFlush = now
|
lastFlush = now
|
||||||
handler.postDelayed(ticker, 1_000)
|
handler.postDelayed(ticker, 1_000)
|
||||||
}
|
}
|
||||||
|
|
@ -303,12 +314,79 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
planWords = json.optJSONObject("say")
|
planWords = json.optJSONObject("say")
|
||||||
cues = PlanCues(this, json.optString("locale").takeIf { it.isNotEmpty() })
|
cuesIn(json.optString("locale"))
|
||||||
plan = PlanRunner(planSteps, offTargetCues = json.optBoolean("offTargetCues", true)).also {
|
plan = PlanRunner(planSteps, offTargetCues = json.optBoolean("offTargetCues", true)).also {
|
||||||
cue(it.start(now, measures.metres), now)
|
cue(it.start(now, measures.metres), now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The cues, in the app's language [locale], made once for the plan and the route. */
|
||||||
|
private fun cuesIn(locale: String) {
|
||||||
|
if (cues == null) cues = Cues(this, locale.takeIf { it.isNotEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follows the route [json] holds (D45): `route`, its payload as the app
|
||||||
|
* keeps it (base64), written into the file; `points`, its latitudes and
|
||||||
|
* longitudes in turn; `turns`, each with its point's index and what is
|
||||||
|
* said; `lead`, how far before a turn it is told; `say`, the words off
|
||||||
|
* the route (`off`, with `{metres}` and `{direction}`, one of the eight
|
||||||
|
* `directions` from north on), back on it and at its end; `locale`.
|
||||||
|
*/
|
||||||
|
private fun followRoute(json: JSONObject, now: Long) {
|
||||||
|
json.optString("route").takeIf { it.isNotEmpty() }?.let { write("route $now $it") }
|
||||||
|
val points = json.optJSONArray("points") ?: return
|
||||||
|
val count = points.length() / 2
|
||||||
|
val turns = json.optJSONArray("turns")
|
||||||
|
guideWords = json.optJSONObject("say")
|
||||||
|
cuesIn(json.optString("locale"))
|
||||||
|
guide =
|
||||||
|
RouteGuide(
|
||||||
|
lats = DoubleArray(count) { points.getDouble(it * 2) },
|
||||||
|
lons = DoubleArray(count) { points.getDouble(it * 2 + 1) },
|
||||||
|
turns =
|
||||||
|
(0 until (turns?.length() ?: 0)).map { i ->
|
||||||
|
val turn = turns!!.getJSONObject(i)
|
||||||
|
GuideTurn(turn.getInt("index"), turn.optString("say"))
|
||||||
|
},
|
||||||
|
leadMetres = json.optInt("lead", 50),
|
||||||
|
)
|
||||||
|
writer?.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes what following the route gave and gives its cues. */
|
||||||
|
private fun guided(events: List<GuideEvent>, at: Long) {
|
||||||
|
val guide = guide ?: return
|
||||||
|
val words = guideWords
|
||||||
|
for (event in events) {
|
||||||
|
when (event) {
|
||||||
|
is GuideEvent.Next -> write("guide $at next ${event.index}")
|
||||||
|
is GuideEvent.Turn -> {
|
||||||
|
write("guide $at turn ${event.index}")
|
||||||
|
cues?.turn(guide.say(event.index))
|
||||||
|
}
|
||||||
|
is GuideEvent.OffRoute -> {
|
||||||
|
write("guide $at off ${event.metres} ${event.bearing}")
|
||||||
|
val directions = words?.optJSONArray("directions")
|
||||||
|
val direction = directions?.optString(((event.bearing + 22) / 45) % 8).orEmpty()
|
||||||
|
cues?.offRoute(
|
||||||
|
words?.optString("off").orEmpty()
|
||||||
|
.replace("{metres}", event.metres.toString())
|
||||||
|
.replace("{direction}", direction),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GuideEvent.Rejoined -> {
|
||||||
|
write("guide $at on")
|
||||||
|
cues?.rejoined(words?.optString("on").orEmpty())
|
||||||
|
}
|
||||||
|
GuideEvent.Arrived -> {
|
||||||
|
write("guide $at arrived")
|
||||||
|
cues?.turn(words?.optString("arrived").orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun lapNow() {
|
private fun lapNow() {
|
||||||
val runner = plan ?: return
|
val runner = plan ?: return
|
||||||
cue(runner.lap(measures.metres), System.currentTimeMillis())
|
cue(runner.lap(measures.metres), System.currentTimeMillis())
|
||||||
|
|
@ -432,6 +510,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
"$accuracy $altitude",
|
"$accuracy $altitude",
|
||||||
)
|
)
|
||||||
measures.fix(at, location.latitude, location.longitude, accuracy)
|
measures.fix(at, location.latitude, location.longitude, accuracy)
|
||||||
|
if (!isPaused()) guide?.let { guided(it.fix(at, location.latitude, location.longitude, accuracy), at) }
|
||||||
if (location.hasSpeed() && !pausedByHand) {
|
if (location.hasSpeed() && !pausedByHand) {
|
||||||
autoPause?.speed(at, location.speed.toDouble())?.let { paused -> mark(paused, auto = true, at = at) }
|
autoPause?.speed(at, location.speed.toDouble())?.let { paused -> mark(paused, auto = true, at = at) }
|
||||||
}
|
}
|
||||||
|
|
@ -485,6 +564,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||||
loopReading?.cancel()
|
loopReading?.cancel()
|
||||||
loopReading = null
|
loopReading = null
|
||||||
plan = null
|
plan = null
|
||||||
|
guide = null
|
||||||
cues?.close()
|
cues?.close()
|
||||||
cues = null
|
cues = null
|
||||||
loopId?.let { PolarSdk.reads(it, now = false) }
|
loopId?.let { PolarSdk.reads(it, now = false) }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
package app.halkyon.train
|
||||||
|
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/** A turn of the route as the recorder tells it (ADR 0030, D45): at the route's point [index], with what is said. */
|
||||||
|
data class GuideTurn(val index: Int, val say: String = "")
|
||||||
|
|
||||||
|
/** What following a route gives, for the recorder to write and cue. */
|
||||||
|
sealed interface GuideEvent {
|
||||||
|
/** The turn to come is the route's turn [index] now; the number of turns where none is left. */
|
||||||
|
data class Next(val index: Int) : GuideEvent
|
||||||
|
|
||||||
|
/** Turn [index] is the lead ahead: told. */
|
||||||
|
data class Turn(val index: Int) : GuideEvent
|
||||||
|
|
||||||
|
/** Off the route: [metres] to its nearest point (to 10 m), which lies at [bearing] degrees from north. */
|
||||||
|
data class OffRoute(val metres: Int, val bearing: Int) : GuideEvent
|
||||||
|
|
||||||
|
data object Rejoined : GuideEvent
|
||||||
|
|
||||||
|
/** Within [ARRIVED_METRES] of the route's end. */
|
||||||
|
data object Arrived : GuideEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follows a route along its points (D45), offline, on each fix: where on
|
||||||
|
* the route the person is, the next turn told [leadMetres] before it; more
|
||||||
|
* than [OFF_METRES] off for [OFF_FOR_MS], a cue with the way back to the
|
||||||
|
* route's nearest point, again every [OFF_AGAIN_MS] while off, and told
|
||||||
|
* again once back within [ON_METRES]. Fixes less precise than the app counts
|
||||||
|
* (D20) are left out, as are those while paused (the recorder doesn't hand
|
||||||
|
* them in).
|
||||||
|
*/
|
||||||
|
class RouteGuide(
|
||||||
|
private val lats: DoubleArray,
|
||||||
|
private val lons: DoubleArray,
|
||||||
|
private val turns: List<GuideTurn>,
|
||||||
|
private val leadMetres: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val OFF_METRES = 40.0
|
||||||
|
const val ON_METRES = 30.0
|
||||||
|
const val OFF_FOR_MS = 10_000L
|
||||||
|
const val OFF_AGAIN_MS = 60_000L
|
||||||
|
const val ARRIVED_METRES = 30.0
|
||||||
|
|
||||||
|
/** How far ahead of where it stands, and behind, a fix is looked for first. */
|
||||||
|
private const val AHEAD_METRES = 500.0
|
||||||
|
private const val BEHIND_METRES = 100.0
|
||||||
|
|
||||||
|
/** How far past a turn it counts as passed. */
|
||||||
|
private const val PASSED_METRES = 10.0
|
||||||
|
private const val EARTH_METRES = 6_371_008.8
|
||||||
|
|
||||||
|
/** The bearing from one point to another, in whole degrees from north, clockwise. */
|
||||||
|
fun bearing(fromLat: Double, fromLon: Double, toLat: Double, toLon: Double): Int {
|
||||||
|
val p1 = Math.toRadians(fromLat)
|
||||||
|
val p2 = Math.toRadians(toLat)
|
||||||
|
val dl = Math.toRadians(toLon - fromLon)
|
||||||
|
val y = Math.sin(dl) * cos(p2)
|
||||||
|
val x = cos(p1) * Math.sin(p2) - Math.sin(p1) * cos(p2) * cos(dl)
|
||||||
|
return ((Math.toDegrees(atan2(y, x)) + 360).roundToInt()) % 360
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far along the route each point is. */
|
||||||
|
private val along =
|
||||||
|
DoubleArray(lats.size).also { along ->
|
||||||
|
for (i in 1 until lats.size) {
|
||||||
|
along[i] = along[i - 1] + PlanMeasures.metresBetween(lats[i - 1], lons[i - 1], lats[i], lons[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val turnAlong = turns.map { along.getOrElse(it.index) { along.lastOrNull() ?: 0.0 } }
|
||||||
|
|
||||||
|
/** How far along the route the person was last seen on it; null before. */
|
||||||
|
private var progress: Double? = null
|
||||||
|
private var next = 0
|
||||||
|
private var nextTold: Int? = null
|
||||||
|
private val told = mutableSetOf<Int>()
|
||||||
|
private var offSince: Long? = null
|
||||||
|
private var lastOffCue: Long? = null
|
||||||
|
private var arrived = false
|
||||||
|
|
||||||
|
/** Whether the person is off the route now. */
|
||||||
|
var off = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Takes the fix [lat], [lon] at [at] in. */
|
||||||
|
fun fix(at: Long, lat: Double, lon: Double, accuracyCm: Long = 0): List<GuideEvent> {
|
||||||
|
if (lats.size < 2 || accuracyCm > PlanMeasures.MAX_ACCURACY_CM) return emptyList()
|
||||||
|
val events = mutableListOf<GuideEvent>()
|
||||||
|
var nearest = nearest(lat, lon, progress)
|
||||||
|
if (nearest.metres > OFF_METRES && progress != null) nearest = nearest(lat, lon, null)
|
||||||
|
if (nearest.metres > OFF_METRES) {
|
||||||
|
val since = offSince ?: at.also { offSince = it }
|
||||||
|
val last = lastOffCue
|
||||||
|
if (at - since >= OFF_FOR_MS && (last == null || at - last >= OFF_AGAIN_MS)) {
|
||||||
|
off = true
|
||||||
|
lastOffCue = at
|
||||||
|
events +=
|
||||||
|
GuideEvent.OffRoute(
|
||||||
|
metres = (nearest.metres / 10).roundToInt() * 10,
|
||||||
|
bearing = bearing(lat, lon, nearest.lat, nearest.lon),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
if (off) {
|
||||||
|
if (nearest.metres > ON_METRES) return events
|
||||||
|
off = false
|
||||||
|
lastOffCue = null
|
||||||
|
events += GuideEvent.Rejoined
|
||||||
|
}
|
||||||
|
offSince = null
|
||||||
|
val now = nearest.along
|
||||||
|
progress = now
|
||||||
|
while (next < turns.size && now > turnAlong[next] + PASSED_METRES) next++
|
||||||
|
if (nextTold != next) {
|
||||||
|
nextTold = next
|
||||||
|
events += GuideEvent.Next(next)
|
||||||
|
}
|
||||||
|
if (next < turns.size && next !in told && turnAlong[next] - now <= leadMetres) {
|
||||||
|
told += next
|
||||||
|
events += GuideEvent.Turn(next)
|
||||||
|
}
|
||||||
|
if (!arrived && along.last() - now <= ARRIVED_METRES) {
|
||||||
|
arrived = true
|
||||||
|
events += GuideEvent.Arrived
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is said for turn [index]. */
|
||||||
|
fun say(index: Int): String = turns.getOrNull(index)?.say.orEmpty()
|
||||||
|
|
||||||
|
private class Nearest(val metres: Double, val along: Double, val lat: Double, val lon: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route's point nearest to [lat], [lon]: among the segments around
|
||||||
|
* [around] where given, else among all.
|
||||||
|
*/
|
||||||
|
private fun nearest(lat: Double, lon: Double, around: Double?): Nearest {
|
||||||
|
var best: Nearest? = null
|
||||||
|
val metresPerLon = EARTH_METRES * Math.toRadians(1.0) * cos(Math.toRadians(lat))
|
||||||
|
val metresPerLat = EARTH_METRES * Math.toRadians(1.0)
|
||||||
|
for (i in 0 until lats.size - 1) {
|
||||||
|
if (around != null && (along[i] > around + AHEAD_METRES || along[i + 1] < around - BEHIND_METRES)) continue
|
||||||
|
// In metres on a plane about the fix, near enough over a segment.
|
||||||
|
val ax = (lons[i] - lon) * metresPerLon
|
||||||
|
val ay = (lats[i] - lat) * metresPerLat
|
||||||
|
val bx = (lons[i + 1] - lon) * metresPerLon
|
||||||
|
val by = (lats[i + 1] - lat) * metresPerLat
|
||||||
|
val dx = bx - ax
|
||||||
|
val dy = by - ay
|
||||||
|
val length = dx * dx + dy * dy
|
||||||
|
val t = if (length == 0.0) 0.0 else (-(ax * dx + ay * dy) / length).coerceIn(0.0, 1.0)
|
||||||
|
val x = ax + t * dx
|
||||||
|
val y = ay + t * dy
|
||||||
|
val metres = sqrt(x * x + y * y)
|
||||||
|
if (best == null || metres < best.metres) {
|
||||||
|
best =
|
||||||
|
Nearest(
|
||||||
|
metres = metres,
|
||||||
|
along = along[i] + t * (along[i + 1] - along[i]),
|
||||||
|
lat = lats[i] + t * (lats[i + 1] - lats[i]),
|
||||||
|
lon = lons[i] + t * (lons[i + 1] - lons[i]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ?: Nearest(Double.MAX_VALUE, 0.0, lat, lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package app.halkyon.train
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Following a route in the recorder (ADR 0030, D45). */
|
||||||
|
class RouteGuideTest {
|
||||||
|
// North along 10° E for about 556 m, then east for about 715 m.
|
||||||
|
private val lats = doubleArrayOf(50.0, 50.005, 50.005)
|
||||||
|
private val lons = doubleArrayOf(10.0, 10.0, 10.01)
|
||||||
|
private val turns = listOf(GuideTurn(index = 1), GuideTurn(index = 2))
|
||||||
|
|
||||||
|
/** A point [metres] north of the start, on the first leg. */
|
||||||
|
private fun north(metres: Double) = 50.0 + metres / 111_195.0
|
||||||
|
|
||||||
|
private fun guide(lead: Int = 50) = RouteGuide(lats, lons, turns, leadMetres = lead)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a turn is told once, its lead before it, and the next turn follows it`() {
|
||||||
|
val guide = guide()
|
||||||
|
val events = mutableListOf<GuideEvent>()
|
||||||
|
events += guide.fix(0, north(0.0), 10.0)
|
||||||
|
events += guide.fix(1_000, north(400.0), 10.0)
|
||||||
|
events += guide.fix(2_000, north(510.0), 10.0)
|
||||||
|
events += guide.fix(3_000, north(520.0), 10.0)
|
||||||
|
events += guide.fix(4_000, 50.005, 10.001)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(GuideEvent.Next(0), GuideEvent.Turn(0), GuideEvent.Next(1)),
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on a bike a turn is told 100 m before it`() {
|
||||||
|
val guide = guide(lead = 100)
|
||||||
|
guide.fix(0, north(0.0), 10.0)
|
||||||
|
assertEquals(emptyList<GuideEvent>(), guide.fix(1_000, north(440.0), 10.0))
|
||||||
|
assertEquals(listOf(GuideEvent.Turn(0)), guide.fix(2_000, north(460.0), 10.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `more than 40 m off for 10 s, a cue and the way back, told again on rejoining`() {
|
||||||
|
val guide = guide()
|
||||||
|
guide.fix(0, north(100.0), 10.0)
|
||||||
|
// 60 m west of the route: 0.00084° of longitude at 50° N.
|
||||||
|
val west = 10.0 - 60 / (111_195.0 * Math.cos(Math.toRadians(50.0)))
|
||||||
|
assertEquals(emptyList<GuideEvent>(), guide.fix(1_000, north(200.0), west))
|
||||||
|
assertEquals(emptyList<GuideEvent>(), guide.fix(9_000, north(200.0), west))
|
||||||
|
val off = guide.fix(11_000, north(200.0), west).single() as GuideEvent.OffRoute
|
||||||
|
assertEquals(60, off.metres)
|
||||||
|
// The route lies east.
|
||||||
|
assertTrue("${off.bearing}", off.bearing in 85..95)
|
||||||
|
assertTrue(guide.off)
|
||||||
|
// Told again a minute on, while still off.
|
||||||
|
assertEquals(emptyList<GuideEvent>(), guide.fix(40_000, north(200.0), west))
|
||||||
|
assertTrue(guide.fix(71_000, north(200.0), west).single() is GuideEvent.OffRoute)
|
||||||
|
assertEquals(listOf(GuideEvent.Rejoined), guide.fix(72_000, north(210.0), 10.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a short stray, or a fix too imprecise, says nothing`() {
|
||||||
|
val guide = guide()
|
||||||
|
guide.fix(0, north(100.0), 10.0)
|
||||||
|
val west = 10.0 - 60 / (111_195.0 * Math.cos(Math.toRadians(50.0)))
|
||||||
|
guide.fix(1_000, north(150.0), west)
|
||||||
|
assertEquals(emptyList<GuideEvent>(), guide.fix(5_000, north(200.0), 10.0))
|
||||||
|
assertEquals(emptyList<GuideEvent>(), guide.fix(20_000, north(200.0), west, accuracyCm = 5_000))
|
||||||
|
assertEquals(emptyList<GuideEvent>(), guide.fix(40_000, north(200.0), west, accuracyCm = 5_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no turn is told while off the route, and a turn passed unseen is skipped`() {
|
||||||
|
val guide = guide()
|
||||||
|
guide.fix(0, north(0.0), 10.0)
|
||||||
|
// Straight on to the second leg's middle: the first turn passed unseen.
|
||||||
|
assertEquals(listOf(GuideEvent.Next(1)), guide.fix(1_000, 50.005, 10.005))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the end is told once, within 30 m of it`() {
|
||||||
|
val guide = guide()
|
||||||
|
guide.fix(0, 50.005, 10.0)
|
||||||
|
guide.fix(1_000, 50.005, 10.009)
|
||||||
|
val events = guide.fix(2_000, 50.005, 10.0097)
|
||||||
|
assertTrue(events.toString(), GuideEvent.Arrived in events)
|
||||||
|
assertEquals(emptyList<GuideEvent>(), guide.fix(3_000, 50.005, 10.01))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the way back points to the nearest point of the route`() {
|
||||||
|
// North of the turn by 100 m: the route lies south.
|
||||||
|
assertEquals(180, RouteGuide.bearing(50.0059, 10.0, 50.005, 10.0))
|
||||||
|
assertEquals(0, RouteGuide.bearing(50.0, 10.0, 50.001, 10.0))
|
||||||
|
assertEquals(270, RouteGuide.bearing(50.0, 10.0, 50.0, 9.999))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -416,5 +416,40 @@
|
||||||
"routeDelete": "Löschen",
|
"routeDelete": "Löschen",
|
||||||
"routeDeleteTitle": "Diese Route löschen?",
|
"routeDeleteTitle": "Diese Route löschen?",
|
||||||
"routeDeleteMessage": "Sie wird auf all deinen Geräten gelöscht.",
|
"routeDeleteMessage": "Sie wird auf all deinen Geräten gelöscht.",
|
||||||
"routeDeleted": "Route gelöscht"
|
"routeDeleted": "Route gelöscht",
|
||||||
|
"turnStraight": "Geradeaus",
|
||||||
|
"turnLeft": "Links abbiegen",
|
||||||
|
"turnSlightLeft": "Leicht links",
|
||||||
|
"turnSharpLeft": "Scharf links",
|
||||||
|
"turnRight": "Rechts abbiegen",
|
||||||
|
"turnSlightRight": "Leicht rechts",
|
||||||
|
"turnSharpRight": "Scharf rechts",
|
||||||
|
"turnKeepLeft": "Links halten",
|
||||||
|
"turnKeepRight": "Rechts halten",
|
||||||
|
"turnAround": "Wenden",
|
||||||
|
"turnRoundabout": "Im Kreisverkehr die {exit}. Ausfahrt nehmen",
|
||||||
|
"turnExitLeft": "Die Ausfahrt links nehmen",
|
||||||
|
"turnExitRight": "Die Ausfahrt rechts nehmen",
|
||||||
|
"routeSayIn": "In {metres} Metern: {turn}",
|
||||||
|
"routeSayOff": "Abseits der Route. Sie liegt {metres} Meter {direction}.",
|
||||||
|
"routeSayOn": "Zurück auf der Route",
|
||||||
|
"routeSayArrived": "Ende der Route",
|
||||||
|
"directionNorth": "nördlich",
|
||||||
|
"directionNorthEast": "nordöstlich",
|
||||||
|
"directionEast": "östlich",
|
||||||
|
"directionSouthEast": "südöstlich",
|
||||||
|
"directionSouth": "südlich",
|
||||||
|
"directionSouthWest": "südwestlich",
|
||||||
|
"directionWest": "westlich",
|
||||||
|
"directionNorthWest": "nordwestlich",
|
||||||
|
"recordRoute": "Route",
|
||||||
|
"recordNoRoute": "Keine",
|
||||||
|
"recordingNextTurn": "In {distance}",
|
||||||
|
"recordingOffRoute": "Abseits der Route: {distance} {direction}",
|
||||||
|
"recordingRouteEnd": "Ende der Route",
|
||||||
|
"recordingFollowRoute": "Route: {name}",
|
||||||
|
"recordingMapHint": "Wischen für die Karte",
|
||||||
|
"recordingFiguresHint": "Zurückwischen für die Werte",
|
||||||
|
"recordingNorthUp": "Norden oben",
|
||||||
|
"recordingHeadingUp": "Bewegungsrichtung oben"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -524,5 +524,46 @@
|
||||||
"routeDelete": "Delete",
|
"routeDelete": "Delete",
|
||||||
"routeDeleteTitle": "Delete this route?",
|
"routeDeleteTitle": "Delete this route?",
|
||||||
"routeDeleteMessage": "It's deleted on all your devices.",
|
"routeDeleteMessage": "It's deleted on all your devices.",
|
||||||
"routeDeleted": "Route deleted"
|
"routeDeleted": "Route deleted",
|
||||||
|
"turnStraight": "Go straight on",
|
||||||
|
"turnLeft": "Turn left",
|
||||||
|
"turnSlightLeft": "Bear left",
|
||||||
|
"turnSharpLeft": "Turn sharp left",
|
||||||
|
"turnRight": "Turn right",
|
||||||
|
"turnSlightRight": "Bear right",
|
||||||
|
"turnSharpRight": "Turn sharp right",
|
||||||
|
"turnKeepLeft": "Keep left",
|
||||||
|
"turnKeepRight": "Keep right",
|
||||||
|
"turnAround": "Turn around",
|
||||||
|
"turnRoundabout": "At the roundabout, take exit {exit}",
|
||||||
|
"@turnRoundabout": {"placeholders": {"exit": {"type": "int"}}},
|
||||||
|
"turnExitLeft": "Take the exit on the left",
|
||||||
|
"turnExitRight": "Take the exit on the right",
|
||||||
|
"routeSayIn": "In {metres} metres: {turn}",
|
||||||
|
"@routeSayIn": {"description": "Spoken before a turn while following a route.", "placeholders": {"metres": {"type": "int"}, "turn": {"type": "String", "example": "Turn left"}}},
|
||||||
|
"routeSayOff": "Off the route. It lies {metres} metres {direction}.",
|
||||||
|
"@routeSayOff": {"description": "Spoken off the route; the recorder puts the figures in.", "placeholders": {"metres": {"type": "String", "example": "80"}, "direction": {"type": "String", "example": "to the north-east"}}},
|
||||||
|
"routeSayOn": "Back on the route",
|
||||||
|
"routeSayArrived": "End of the route",
|
||||||
|
"directionNorth": "to the north",
|
||||||
|
"directionNorthEast": "to the north-east",
|
||||||
|
"directionEast": "to the east",
|
||||||
|
"directionSouthEast": "to the south-east",
|
||||||
|
"directionSouth": "to the south",
|
||||||
|
"directionSouthWest": "to the south-west",
|
||||||
|
"directionWest": "to the west",
|
||||||
|
"directionNorthWest": "to the north-west",
|
||||||
|
"recordRoute": "Route",
|
||||||
|
"recordNoRoute": "None",
|
||||||
|
"recordingNextTurn": "In {distance}",
|
||||||
|
"@recordingNextTurn": {"placeholders": {"distance": {"type": "String", "example": "320 m"}}},
|
||||||
|
"recordingOffRoute": "Off the route: {distance} {direction}",
|
||||||
|
"@recordingOffRoute": {"placeholders": {"distance": {"type": "String", "example": "80 m"}, "direction": {"type": "String", "example": "to the north-east"}}},
|
||||||
|
"recordingRouteEnd": "End of the route",
|
||||||
|
"recordingFollowRoute": "Following {name}",
|
||||||
|
"@recordingFollowRoute": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"recordingMapHint": "Swipe for the map",
|
||||||
|
"recordingFiguresHint": "Swipe back for the figures",
|
||||||
|
"recordingNorthUp": "North up",
|
||||||
|
"recordingHeadingUp": "Heading up"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2523,6 +2523,216 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Route deleted'**
|
/// **'Route deleted'**
|
||||||
String get routeDeleted;
|
String get routeDeleted;
|
||||||
|
|
||||||
|
/// No description provided for @turnStraight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Go straight on'**
|
||||||
|
String get turnStraight;
|
||||||
|
|
||||||
|
/// No description provided for @turnLeft.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Turn left'**
|
||||||
|
String get turnLeft;
|
||||||
|
|
||||||
|
/// No description provided for @turnSlightLeft.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bear left'**
|
||||||
|
String get turnSlightLeft;
|
||||||
|
|
||||||
|
/// No description provided for @turnSharpLeft.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Turn sharp left'**
|
||||||
|
String get turnSharpLeft;
|
||||||
|
|
||||||
|
/// No description provided for @turnRight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Turn right'**
|
||||||
|
String get turnRight;
|
||||||
|
|
||||||
|
/// No description provided for @turnSlightRight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bear right'**
|
||||||
|
String get turnSlightRight;
|
||||||
|
|
||||||
|
/// No description provided for @turnSharpRight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Turn sharp right'**
|
||||||
|
String get turnSharpRight;
|
||||||
|
|
||||||
|
/// No description provided for @turnKeepLeft.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep left'**
|
||||||
|
String get turnKeepLeft;
|
||||||
|
|
||||||
|
/// No description provided for @turnKeepRight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep right'**
|
||||||
|
String get turnKeepRight;
|
||||||
|
|
||||||
|
/// No description provided for @turnAround.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Turn around'**
|
||||||
|
String get turnAround;
|
||||||
|
|
||||||
|
/// No description provided for @turnRoundabout.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'At the roundabout, take exit {exit}'**
|
||||||
|
String turnRoundabout(int exit);
|
||||||
|
|
||||||
|
/// No description provided for @turnExitLeft.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Take the exit on the left'**
|
||||||
|
String get turnExitLeft;
|
||||||
|
|
||||||
|
/// No description provided for @turnExitRight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Take the exit on the right'**
|
||||||
|
String get turnExitRight;
|
||||||
|
|
||||||
|
/// Spoken before a turn while following a route.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'In {metres} metres: {turn}'**
|
||||||
|
String routeSayIn(int metres, String turn);
|
||||||
|
|
||||||
|
/// Spoken off the route; the recorder puts the figures in.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Off the route. It lies {metres} metres {direction}.'**
|
||||||
|
String routeSayOff(String metres, String direction);
|
||||||
|
|
||||||
|
/// No description provided for @routeSayOn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back on the route'**
|
||||||
|
String get routeSayOn;
|
||||||
|
|
||||||
|
/// No description provided for @routeSayArrived.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'End of the route'**
|
||||||
|
String get routeSayArrived;
|
||||||
|
|
||||||
|
/// No description provided for @directionNorth.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'to the north'**
|
||||||
|
String get directionNorth;
|
||||||
|
|
||||||
|
/// No description provided for @directionNorthEast.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'to the north-east'**
|
||||||
|
String get directionNorthEast;
|
||||||
|
|
||||||
|
/// No description provided for @directionEast.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'to the east'**
|
||||||
|
String get directionEast;
|
||||||
|
|
||||||
|
/// No description provided for @directionSouthEast.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'to the south-east'**
|
||||||
|
String get directionSouthEast;
|
||||||
|
|
||||||
|
/// No description provided for @directionSouth.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'to the south'**
|
||||||
|
String get directionSouth;
|
||||||
|
|
||||||
|
/// No description provided for @directionSouthWest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'to the south-west'**
|
||||||
|
String get directionSouthWest;
|
||||||
|
|
||||||
|
/// No description provided for @directionWest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'to the west'**
|
||||||
|
String get directionWest;
|
||||||
|
|
||||||
|
/// No description provided for @directionNorthWest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'to the north-west'**
|
||||||
|
String get directionNorthWest;
|
||||||
|
|
||||||
|
/// No description provided for @recordRoute.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Route'**
|
||||||
|
String get recordRoute;
|
||||||
|
|
||||||
|
/// No description provided for @recordNoRoute.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'None'**
|
||||||
|
String get recordNoRoute;
|
||||||
|
|
||||||
|
/// No description provided for @recordingNextTurn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'In {distance}'**
|
||||||
|
String recordingNextTurn(String distance);
|
||||||
|
|
||||||
|
/// No description provided for @recordingOffRoute.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Off the route: {distance} {direction}'**
|
||||||
|
String recordingOffRoute(String distance, String direction);
|
||||||
|
|
||||||
|
/// No description provided for @recordingRouteEnd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'End of the route'**
|
||||||
|
String get recordingRouteEnd;
|
||||||
|
|
||||||
|
/// No description provided for @recordingFollowRoute.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Following {name}'**
|
||||||
|
String recordingFollowRoute(String name);
|
||||||
|
|
||||||
|
/// No description provided for @recordingMapHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Swipe for the map'**
|
||||||
|
String get recordingMapHint;
|
||||||
|
|
||||||
|
/// No description provided for @recordingFiguresHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Swipe back for the figures'**
|
||||||
|
String get recordingFiguresHint;
|
||||||
|
|
||||||
|
/// No description provided for @recordingNorthUp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'North up'**
|
||||||
|
String get recordingNorthUp;
|
||||||
|
|
||||||
|
/// No description provided for @recordingHeadingUp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Heading up'**
|
||||||
|
String get recordingHeadingUp;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -1499,4 +1499,121 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get routeDeleted => 'Route gelöscht';
|
String get routeDeleted => 'Route gelöscht';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnStraight => 'Geradeaus';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnLeft => 'Links abbiegen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnSlightLeft => 'Leicht links';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnSharpLeft => 'Scharf links';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnRight => 'Rechts abbiegen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnSlightRight => 'Leicht rechts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnSharpRight => 'Scharf rechts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnKeepLeft => 'Links halten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnKeepRight => 'Rechts halten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnAround => 'Wenden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String turnRoundabout(int exit) {
|
||||||
|
return 'Im Kreisverkehr die $exit. Ausfahrt nehmen';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnExitLeft => 'Die Ausfahrt links nehmen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnExitRight => 'Die Ausfahrt rechts nehmen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String routeSayIn(int metres, String turn) {
|
||||||
|
return 'In $metres Metern: $turn';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String routeSayOff(String metres, String direction) {
|
||||||
|
return 'Abseits der Route. Sie liegt $metres Meter $direction.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get routeSayOn => 'Zurück auf der Route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get routeSayArrived => 'Ende der Route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionNorth => 'nördlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionNorthEast => 'nordöstlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionEast => 'östlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionSouthEast => 'südöstlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionSouth => 'südlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionSouthWest => 'südwestlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionWest => 'westlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionNorthWest => 'nordwestlich';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordRoute => 'Route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordNoRoute => 'Keine';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingNextTurn(String distance) {
|
||||||
|
return 'In $distance';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingOffRoute(String distance, String direction) {
|
||||||
|
return 'Abseits der Route: $distance $direction';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRouteEnd => 'Ende der Route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingFollowRoute(String name) {
|
||||||
|
return 'Route: $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingMapHint => 'Wischen für die Karte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingFiguresHint => 'Zurückwischen für die Werte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingNorthUp => 'Norden oben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingHeadingUp => 'Bewegungsrichtung oben';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1491,6 +1491,123 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get routeDeleted => 'Route deleted';
|
String get routeDeleted => 'Route deleted';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnStraight => 'Go straight on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnLeft => 'Turn left';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnSlightLeft => 'Bear left';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnSharpLeft => 'Turn sharp left';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnRight => 'Turn right';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnSlightRight => 'Bear right';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnSharpRight => 'Turn sharp right';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnKeepLeft => 'Keep left';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnKeepRight => 'Keep right';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnAround => 'Turn around';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String turnRoundabout(int exit) {
|
||||||
|
return 'At the roundabout, take exit $exit';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnExitLeft => 'Take the exit on the left';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get turnExitRight => 'Take the exit on the right';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String routeSayIn(int metres, String turn) {
|
||||||
|
return 'In $metres metres: $turn';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String routeSayOff(String metres, String direction) {
|
||||||
|
return 'Off the route. It lies $metres metres $direction.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get routeSayOn => 'Back on the route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get routeSayArrived => 'End of the route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionNorth => 'to the north';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionNorthEast => 'to the north-east';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionEast => 'to the east';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionSouthEast => 'to the south-east';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionSouth => 'to the south';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionSouthWest => 'to the south-west';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionWest => 'to the west';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get directionNorthWest => 'to the north-west';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordRoute => 'Route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordNoRoute => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingNextTurn(String distance) {
|
||||||
|
return 'In $distance';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingOffRoute(String distance, String direction) {
|
||||||
|
return 'Off the route: $distance $direction';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingRouteEnd => 'End of the route';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String recordingFollowRoute(String name) {
|
||||||
|
return 'Following $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingMapHint => 'Swipe for the map';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingFiguresHint => 'Swipe back for the figures';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingNorthUp => 'North up';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get recordingHeadingUp => 'Heading up';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ 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/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_speech.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
|
||||||
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
|
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
|
@ -153,6 +154,12 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
|
||||||
maxHeartRate: planMaxHeartRate(profile, clock.now()),
|
maxHeartRate: planMaxHeartRate(profile, clock.now()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
routeOf: (route, activity) => recorderRouteOf(
|
||||||
|
AppLocalizations.of(context),
|
||||||
|
Localizations.localeOf(context).toLanguageTag(),
|
||||||
|
route,
|
||||||
|
activity,
|
||||||
|
),
|
||||||
)..add(const RecordingEventOpened()),
|
)..add(const RecordingEventOpened()),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platfo
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
|
|
@ -25,7 +26,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
/// it is switched off for it (D27), and their rower, which a row needs
|
/// it is switched off for it (D27), and their rower, which a row needs
|
||||||
/// (D28); their Loop, where they have one, gives the heart rate while no
|
/// (D28); their Loop, where they have one, gives the heart rate while no
|
||||||
/// strap does (D41). A recording may run an interval plan (D42), which the
|
/// strap does (D41). A recording may run an interval plan (D42), which the
|
||||||
/// recorder runs, its cues given there.
|
/// recorder runs, its cues given there, and follow a route (D45), which the
|
||||||
|
/// recorder guides along.
|
||||||
///
|
///
|
||||||
/// The recorder decides the pauses (D16); this works out what the screen and
|
/// The recorder decides the pauses (D16); this works out what the screen and
|
||||||
/// the notification show from the same lines the file holds.
|
/// the notification show from the same lines the file holds.
|
||||||
|
|
@ -52,6 +54,11 @@ class RecordingBloc(
|
||||||
/// A plan as the recorder takes it (`recorderPlanOf`), in the app's
|
/// A plan as the recorder takes it (`recorderPlanOf`), in the app's
|
||||||
/// language and with the person's heart rates.
|
/// language and with the person's heart rates.
|
||||||
final Future<Map<String, Object?>> Function(PlanContent plan)? planOf,
|
final Future<Map<String, Object?>> Function(PlanContent plan)? planOf,
|
||||||
|
|
||||||
|
/// A route as the recorder takes it (`recorderRouteOf`) for the activity
|
||||||
|
/// recorded, in the app's language.
|
||||||
|
final Map<String, Object?> Function(RouteContent route, Activity activity)?
|
||||||
|
routeOf,
|
||||||
}) extends Bloc<RecordingEvent, RecordingState> {
|
}) extends Bloc<RecordingEvent, RecordingState> {
|
||||||
this : super(const RecordingState()) {
|
this : super(const RecordingState()) {
|
||||||
on<RecordingEventOpened>(_onOpened);
|
on<RecordingEventOpened>(_onOpened);
|
||||||
|
|
@ -185,6 +192,11 @@ class RecordingBloc(
|
||||||
(final plan?, final planOf?) => await planOf(plan),
|
(final plan?, final planOf?) => await planOf(plan),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
final route = switch ((event.route, routeOf)) {
|
||||||
|
(final route?, final routeOf?) when gps && route.planned != null =>
|
||||||
|
routeOf(route, activity),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
final now = clock.now().toUtc();
|
final now = clock.now().toUtc();
|
||||||
final notice = _notice(
|
final notice = _notice(
|
||||||
liveOf(Recording(activity: activity, startedAt: now), now),
|
liveOf(Recording(activity: activity, startedAt: now), now),
|
||||||
|
|
@ -198,6 +210,7 @@ class RecordingBloc(
|
||||||
rower: rows ? rower?.address : null,
|
rower: rows ? rower?.address : null,
|
||||||
loop: loop?.call(),
|
loop: loop?.call(),
|
||||||
plan: plan,
|
plan: plan,
|
||||||
|
route: route,
|
||||||
);
|
);
|
||||||
_shown = _keyOf(notice);
|
_shown = _keyOf(notice);
|
||||||
_follow();
|
_follow();
|
||||||
|
|
@ -456,12 +469,14 @@ 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).
|
/// [plan] where one was chosen (D42), following [route] where one was
|
||||||
|
/// (D45).
|
||||||
final class const RecordingEventStartRequested(
|
final class const RecordingEventStartRequested(
|
||||||
final Activity activity, {
|
final Activity activity, {
|
||||||
final bool autoPause = true,
|
final bool autoPause = true,
|
||||||
final bool useStrap = true,
|
final bool useStrap = true,
|
||||||
final PlanContent? plan,
|
final PlanContent? plan,
|
||||||
|
final RouteContent? route,
|
||||||
}) extends RecordingEvent;
|
}) extends RecordingEvent;
|
||||||
|
|
||||||
/// The person chose [device] as their [kind] on this device.
|
/// The person chose [device] as their [kind] on this device.
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
String? rower,
|
String? rower,
|
||||||
String? loop,
|
String? loop,
|
||||||
Map<String, Object?>? plan,
|
Map<String, Object?>? plan,
|
||||||
|
Map<String, Object?>? route,
|
||||||
}) => _channel.invokeMethod<void>('start', {
|
}) => _channel.invokeMethod<void>('start', {
|
||||||
'path': path,
|
'path': path,
|
||||||
'activity': activity.name,
|
'activity': activity.name,
|
||||||
|
|
@ -47,6 +48,7 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
|
||||||
'rower': rower,
|
'rower': rower,
|
||||||
'loop': loop,
|
'loop': loop,
|
||||||
'plan': plan == null ? null : jsonEncode(plan),
|
'plan': plan == null ? null : jsonEncode(plan),
|
||||||
|
'route': route == null ? null : jsonEncode(route),
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workou
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Whether [activity] is recorded with GPS: all but rowing indoors.
|
/// Whether [activity] is recorded with GPS: all but rowing indoors.
|
||||||
bool usesGps(Activity activity) => activity != Activity.indoorRowing;
|
bool usesGps(Activity activity) => activity != Activity.indoorRowing;
|
||||||
|
|
@ -56,6 +57,11 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
||||||
final lastRowing = recording.rowing.lastOrNull;
|
final lastRowing = recording.rowing.lastOrNull;
|
||||||
final rowingFresh =
|
final rowingFresh =
|
||||||
lastRowing != null && now.difference(lastRowing.time) <= freshHeartRate;
|
lastRowing != null && now.difference(lastRowing.time) <= freshHeartRate;
|
||||||
|
final track = [
|
||||||
|
for (final fix in recording.fixes)
|
||||||
|
if (fix.accuracyCm <= maxAccuracyCm)
|
||||||
|
LatLng(fix.latE7 / 1e7, fix.lonE7 / 1e7),
|
||||||
|
];
|
||||||
return LiveRecording(
|
return LiveRecording(
|
||||||
activity: recording.activity,
|
activity: recording.activity,
|
||||||
movingTime: movingTimeOf(recording, now),
|
movingTime: movingTimeOf(recording, now),
|
||||||
|
|
@ -83,9 +89,70 @@ LiveRecording liveOf(Recording recording, DateTime now) {
|
||||||
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
|
strokeRate: rowingFresh && !paused ? lastRowing.strokeRate : null,
|
||||||
watts: rowingFresh && !paused ? lastRowing.watts : null,
|
watts: rowingFresh && !paused ? lastRowing.watts : null,
|
||||||
plan: _planOf(recording, now),
|
plan: _planOf(recording, now),
|
||||||
|
route: _routeOf(recording, track.lastOrNull),
|
||||||
|
track: track,
|
||||||
|
heading: _headingOf(track),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _distance = Distance();
|
||||||
|
|
||||||
|
/// Where following [recording]'s route stands (D45), the person last at
|
||||||
|
/// [here]; null without a route or a way planned.
|
||||||
|
LiveRoute? _routeOf(Recording recording, LatLng? here) {
|
||||||
|
final route = recording.route;
|
||||||
|
final planned = route?.planned;
|
||||||
|
if (route == null || planned == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
GuideMark? last(Set<GuideKind> kinds) {
|
||||||
|
for (final mark in recording.guidance.reversed) {
|
||||||
|
if (kinds.contains(mark.kind)) {
|
||||||
|
return mark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final next = switch (last({GuideKind.next})) {
|
||||||
|
GuideMark(:final index) when index < planned.hints.length =>
|
||||||
|
planned.hints[index],
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
final off = switch (last({GuideKind.off, GuideKind.on})) {
|
||||||
|
final mark? when mark.kind == GuideKind.off => mark,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
final turnAt = next == null
|
||||||
|
? null
|
||||||
|
: planned.points.elementAtOrNull(next.index);
|
||||||
|
return LiveRoute(
|
||||||
|
route: route,
|
||||||
|
next: next,
|
||||||
|
metresToNext: here == null || turnAt == null
|
||||||
|
? null
|
||||||
|
: (_distance(here, turnAt) / 10).round() * 10,
|
||||||
|
offMetres: off?.metres,
|
||||||
|
offBearing: off?.bearing,
|
||||||
|
arrived: recording.guidance.any((mark) => mark.kind == GuideKind.arrived),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading of [track]'s last stretch of 5 m or more, in degrees from
|
||||||
|
/// north; null where it has none.
|
||||||
|
double? _headingOf(List<LatLng> track) {
|
||||||
|
final last = track.lastOrNull;
|
||||||
|
if (last == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final point in track.reversed.skip(1)) {
|
||||||
|
if (_distance(point, last) >= 5) {
|
||||||
|
return (_distance.bearing(point, last) + 360) % 360;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Where [recording]'s plan stands at [now] (D42), from its last step mark:
|
/// Where [recording]'s plan stands at [now] (D42), from its last step mark:
|
||||||
/// the moving time and the distance since, as the recorder counts them; null
|
/// the moving time and the distance since, as the recorder counts them; null
|
||||||
/// without a plan, or before its first step.
|
/// without a plan, or before its first step.
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,10 @@ abstract interface class RecorderPlatform() {
|
||||||
/// The interval plan it runs, as the recorder takes it (D42,
|
/// The interval plan it runs, as the recorder takes it (D42,
|
||||||
/// `recorderPlanOf`).
|
/// `recorderPlanOf`).
|
||||||
Map<String, Object?>? plan,
|
Map<String, Object?>? plan,
|
||||||
|
|
||||||
|
/// The route it follows, as the recorder takes it (D45,
|
||||||
|
/// `recorderRouteOf`).
|
||||||
|
Map<String, Object?>? route,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Pauses, or goes on, by hand.
|
/// Pauses, or goes on, by hand.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'dart:typed_data';
|
||||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
|
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/data/route_payload.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
/// The version of the recording file this app writes and reads.
|
/// The version of the recording file this app writes and reads.
|
||||||
|
|
@ -29,6 +31,10 @@ const recordingFileMagic = 'halkyon-train-recording';
|
||||||
/// loop <time> connecting|connected|lost
|
/// loop <time> connecting|connected|lost
|
||||||
/// plan <time> <the plan's payload, JSON>
|
/// plan <time> <the plan's payload, JSON>
|
||||||
/// step <time> <its index in the plan's run>|done
|
/// step <time> <its index in the plan's run>|done
|
||||||
|
/// route <time> <the route's payload, base64>
|
||||||
|
/// guide <time> next|turn <the route's hint>
|
||||||
|
/// guide <time> off <metres> <bearing>
|
||||||
|
/// guide <time> on|arrived
|
||||||
/// end <time>
|
/// end <time>
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
|
|
@ -67,6 +73,8 @@ Recording? readRecording(String text) {
|
||||||
PlanContent? plan;
|
PlanContent? plan;
|
||||||
int? planMaxHeartRate;
|
int? planMaxHeartRate;
|
||||||
final planSteps = <StepMark>[];
|
final planSteps = <StepMark>[];
|
||||||
|
RouteContent? route;
|
||||||
|
final guidance = <GuideMark>[];
|
||||||
DateTime? endedAt;
|
DateTime? endedAt;
|
||||||
for (final line in lines.skip(1)) {
|
for (final line in lines.skip(1)) {
|
||||||
final fields = line.split(' ');
|
final fields = line.split(' ');
|
||||||
|
|
@ -85,6 +93,37 @@ Recording? readRecording(String text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
switch (fields) {
|
switch (fields) {
|
||||||
|
case ['route', _, final payload]:
|
||||||
|
route = _routeOf(payload);
|
||||||
|
case ['guide', _, final kind, final index]
|
||||||
|
when kind == 'next' || kind == 'turn':
|
||||||
|
if (int.tryParse(index) case final index? when index >= 0) {
|
||||||
|
guidance.add(
|
||||||
|
GuideMark(
|
||||||
|
time: time,
|
||||||
|
kind: kind == 'next' ? GuideKind.next : GuideKind.turn,
|
||||||
|
index: index,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case ['guide', _, 'off', final metres, final bearing]:
|
||||||
|
if ((int.tryParse(metres), int.tryParse(bearing)) case (
|
||||||
|
final metres?,
|
||||||
|
final bearing?,
|
||||||
|
)) {
|
||||||
|
guidance.add(
|
||||||
|
GuideMark(
|
||||||
|
time: time,
|
||||||
|
kind: GuideKind.off,
|
||||||
|
metres: metres,
|
||||||
|
bearing: bearing,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case ['guide', _, 'on']:
|
||||||
|
guidance.add(GuideMark(time: time, kind: GuideKind.on));
|
||||||
|
case ['guide', _, 'arrived']:
|
||||||
|
guidance.add(GuideMark(time: time, kind: GuideKind.arrived));
|
||||||
case ['step', _, 'done']:
|
case ['step', _, 'done']:
|
||||||
planSteps.add(StepMark(time: time));
|
planSteps.add(StepMark(time: time));
|
||||||
case ['step', _, final index]:
|
case ['step', _, final index]:
|
||||||
|
|
@ -157,12 +196,22 @@ Recording? readRecording(String text) {
|
||||||
plan: plan,
|
plan: plan,
|
||||||
planSteps: planSteps,
|
planSteps: planSteps,
|
||||||
planMaxHeartRate: planMaxHeartRate,
|
planMaxHeartRate: planMaxHeartRate,
|
||||||
|
route: route,
|
||||||
|
guidance: guidance,
|
||||||
endedAt: endedAt,
|
endedAt: endedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RouteContent? _routeOf(String payload) {
|
||||||
|
try {
|
||||||
|
return decodeRoute(ByteData.sublistView(base64Decode(payload)));
|
||||||
|
} on FormatException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Object? _jsonOf(String text) {
|
Object? _jsonOf(String text) {
|
||||||
try {
|
try {
|
||||||
return jsonDecode(text);
|
return jsonDecode(text);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Where a recording's plan stands (D42): the step running, which of how
|
/// Where a recording's plan stands (D42): the step running, which of how
|
||||||
/// many, what is left of it, and the next; no step once the plan is done.
|
/// many, what is left of it, and the next; no step once the plan is done.
|
||||||
|
|
@ -35,6 +37,36 @@ final class const LivePlan({
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where following a recording's route stands (D45): the next turn and how
|
||||||
|
/// far it is as the crow flies (to 10 m), the way back while off the route,
|
||||||
|
/// and whether its end was reached.
|
||||||
|
final class const LiveRoute({
|
||||||
|
required final RouteContent route,
|
||||||
|
|
||||||
|
/// The turn to come; null before the recorder placed the person on the
|
||||||
|
/// route, and past its last turn.
|
||||||
|
final TurnHint? next,
|
||||||
|
final int? metresToNext,
|
||||||
|
|
||||||
|
/// While off the route: how far its nearest point is, and its bearing,
|
||||||
|
/// in degrees from north.
|
||||||
|
final int? offMetres,
|
||||||
|
final int? offBearing,
|
||||||
|
final bool arrived = false,
|
||||||
|
}) with Equatable {
|
||||||
|
bool get off => offMetres != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
route,
|
||||||
|
next,
|
||||||
|
metresToNext,
|
||||||
|
offMetres,
|
||||||
|
offBearing,
|
||||||
|
arrived,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// What the recording screen shows of a recording under way (ADR 0030, D21).
|
/// What the recording screen shows of a recording under way (ADR 0030, D21).
|
||||||
final class const LiveRecording({
|
final class const LiveRecording({
|
||||||
required final Activity activity,
|
required final Activity activity,
|
||||||
|
|
@ -83,6 +115,14 @@ final class const LiveRecording({
|
||||||
|
|
||||||
/// The plan it runs; null without one (D42).
|
/// The plan it runs; null without one (D42).
|
||||||
final LivePlan? plan,
|
final LivePlan? plan,
|
||||||
|
|
||||||
|
/// The route it follows; null without one (D45).
|
||||||
|
final LiveRoute? route,
|
||||||
|
|
||||||
|
/// The fixes precise enough to count (D20), for the map, and the heading
|
||||||
|
/// of the last few metres, in degrees from north; null before it moved.
|
||||||
|
final List<LatLng> track = const [],
|
||||||
|
final double? heading,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
|
|
@ -103,5 +143,8 @@ final class const LiveRecording({
|
||||||
strokeRate,
|
strokeRate,
|
||||||
watts,
|
watts,
|
||||||
plan,
|
plan,
|
||||||
|
route,
|
||||||
|
track,
|
||||||
|
heading,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
/// A position the phone delivered (ADR 0030, D20: every one, with its
|
/// A position the phone delivered (ADR 0030, D20: every one, with its
|
||||||
|
|
@ -91,6 +92,38 @@ final class const StepMark({
|
||||||
List<Object?> get props => [time, index];
|
List<Object?> get props => [time, index];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What following a route gave (D45), as the recorder tells it.
|
||||||
|
enum GuideKind() {
|
||||||
|
/// The turn to come is the route's hint [GuideMark.index] now; none where
|
||||||
|
/// the index is past its last.
|
||||||
|
next,
|
||||||
|
|
||||||
|
/// Hint [GuideMark.index] was told, ahead.
|
||||||
|
turn,
|
||||||
|
|
||||||
|
/// Off the route, [GuideMark.metres] from its nearest point, which lies
|
||||||
|
/// [GuideMark.bearing] degrees from north.
|
||||||
|
off,
|
||||||
|
|
||||||
|
/// Back on it.
|
||||||
|
on,
|
||||||
|
|
||||||
|
/// At its end.
|
||||||
|
arrived,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mark of following the route at [time].
|
||||||
|
final class const GuideMark({
|
||||||
|
required final DateTime time,
|
||||||
|
required final GuideKind kind,
|
||||||
|
final int index = 0,
|
||||||
|
final int metres = 0,
|
||||||
|
final int bearing = 0,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [time, kind, index, metres, bearing];
|
||||||
|
}
|
||||||
|
|
||||||
/// A recording as its file holds it (D18): what the recorder took, in the
|
/// A recording as its file holds it (D18): what the recorder took, in the
|
||||||
/// order it took it, finished or cut off.
|
/// order it took it, finished or cut off.
|
||||||
final class const Recording({
|
final class const Recording({
|
||||||
|
|
@ -121,6 +154,11 @@ final class const Recording({
|
||||||
final List<StepMark> planSteps = const [],
|
final List<StepMark> planSteps = const [],
|
||||||
final int? planMaxHeartRate,
|
final int? planMaxHeartRate,
|
||||||
|
|
||||||
|
/// The route it followed, where it followed one, and what following it
|
||||||
|
/// gave (D45).
|
||||||
|
final RouteContent? route,
|
||||||
|
final List<GuideMark> guidance = const [],
|
||||||
|
|
||||||
/// When the person stopped it; null for one cut off (D18).
|
/// When the person stopped it; null for one cut off (D18).
|
||||||
final DateTime? endedAt,
|
final DateTime? endedAt,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
|
|
@ -153,6 +191,8 @@ final class const Recording({
|
||||||
plan,
|
plan,
|
||||||
planSteps,
|
planSteps,
|
||||||
planMaxHeartRate,
|
planMaxHeartRate,
|
||||||
|
route,
|
||||||
|
guidance,
|
||||||
endedAt,
|
endedAt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.
|
||||||
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/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/model/route.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/route/widget/route_texts.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
@ -34,7 +37,8 @@ bool autoPausesByDefault(Activity activity) =>
|
||||||
/// recorded chosen, auto-pause's switch, the heart-rate strap, chosen there
|
/// recorded chosen, auto-pause's switch, the heart-rate strap, chosen there
|
||||||
/// and switched off for one workout (D27), the Loop named where it gives the
|
/// and switched off for one workout (D27), the Loop named where it gives the
|
||||||
/// 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), and the interval plan to run, none by default (D42); true once
|
/// (D28), the interval plan to run, none by default (D42), and the route
|
||||||
|
/// to follow, none by default, for an activity with GPS (D45); 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>(
|
||||||
|
|
@ -62,6 +66,15 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
||||||
/// The plan it runs; none by default.
|
/// The plan it runs; none by default.
|
||||||
PlanContent? _plan;
|
PlanContent? _plan;
|
||||||
|
|
||||||
|
/// The route it follows; none by default.
|
||||||
|
RouteContent? _route;
|
||||||
|
|
||||||
|
/// [activity] chosen, with its auto-pause's default.
|
||||||
|
void _choose(Activity activity) {
|
||||||
|
_activity = activity;
|
||||||
|
_autoPause = autoPausesByDefault(activity);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
@ -72,10 +85,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
||||||
children: [
|
children: [
|
||||||
RadioGroup<Activity>(
|
RadioGroup<Activity>(
|
||||||
groupValue: _activity,
|
groupValue: _activity,
|
||||||
onChanged: (activity) => setState(() {
|
onChanged: (activity) => setState(() => _choose(activity!)),
|
||||||
_activity = activity!;
|
|
||||||
_autoPause = autoPausesByDefault(activity);
|
|
||||||
}),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final activity in recordedActivities)
|
for (final activity in recordedActivities)
|
||||||
|
|
@ -111,7 +121,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
trailing: const Icon(LucideIcons.chevronRight),
|
trailing: const Icon(LucideIcons.chevronRight),
|
||||||
onTap: () => _choose(context, DeviceKind.rower),
|
onTap: () => _chooseDevice(context, DeviceKind.rower),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlocBuilder<RecordingBloc, RecordingState>(
|
BlocBuilder<RecordingBloc, RecordingState>(
|
||||||
|
|
@ -139,7 +149,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
||||||
value: _useStrap,
|
value: _useStrap,
|
||||||
onChanged: (value) => setState(() => _useStrap = value),
|
onChanged: (value) => setState(() => _useStrap = value),
|
||||||
),
|
),
|
||||||
onTap: () => _choose(context, DeviceKind.strap),
|
onTap: () => _chooseDevice(context, DeviceKind.strap),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -156,13 +166,32 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
||||||
// A plan for an activity chooses it.
|
// A plan for an activity chooses it.
|
||||||
if (chosen.plan?.activity case final activity?
|
if (chosen.plan?.activity case final activity?
|
||||||
when recordedActivities.contains(activity)) {
|
when recordedActivities.contains(activity)) {
|
||||||
_activity = activity;
|
_choose(activity);
|
||||||
_autoPause = autoPausesByDefault(activity);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (_activity != Activity.indoorRowing)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.route),
|
||||||
|
title: Text(l10n.recordRoute),
|
||||||
|
subtitle: Text(_route?.name ?? l10n.recordNoRoute),
|
||||||
|
trailing: const Icon(LucideIcons.chevronRight),
|
||||||
|
onTap: () async {
|
||||||
|
final chosen = await _chooseRoute(context, _route);
|
||||||
|
if (chosen != null) {
|
||||||
|
setState(() {
|
||||||
|
_route = chosen.route;
|
||||||
|
// A route for an activity chooses it, as a plan does.
|
||||||
|
if (chosen.route?.activity case final activity?
|
||||||
|
when recordedActivities.contains(activity)) {
|
||||||
|
_choose(activity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
footer: BlocBuilder<RecordingBloc, RecordingState>(
|
footer: BlocBuilder<RecordingBloc, RecordingState>(
|
||||||
|
|
@ -179,6 +208,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
||||||
autoPause: _autoPause,
|
autoPause: _autoPause,
|
||||||
useStrap: _useStrap,
|
useStrap: _useStrap,
|
||||||
plan: _plan,
|
plan: _plan,
|
||||||
|
route: _activity == Activity.indoorRowing ? null : _route,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop(true);
|
Navigator.of(context).pop(true);
|
||||||
|
|
@ -189,7 +219,7 @@ class _RecordSheetState() extends State<_RecordSheet> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _choose(BuildContext context, DeviceKind kind) =>
|
Future<void> _chooseDevice(BuildContext context, DeviceKind kind) =>
|
||||||
showDeviceSheet(
|
showDeviceSheet(
|
||||||
context,
|
context,
|
||||||
widget.bloc,
|
widget.bloc,
|
||||||
|
|
@ -236,6 +266,43 @@ Future<({PlanContent? plan})?> _choosePlan(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The routes with a way planned to choose from, with None; null where the
|
||||||
|
/// sheet was left without a choice.
|
||||||
|
Future<({RouteContent? route})?> _chooseRoute(
|
||||||
|
BuildContext context,
|
||||||
|
RouteContent? current,
|
||||||
|
) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
return showAdaptiveSheet<({RouteContent? route})>(
|
||||||
|
context,
|
||||||
|
builder: (context) => SheetScaffold(
|
||||||
|
title: l10n.recordRoute,
|
||||||
|
body: BlocBuilder<RoutesCubit, RoutesState>(
|
||||||
|
builder: (context, state) => RadioGroup<RouteContent?>(
|
||||||
|
groupValue: current,
|
||||||
|
onChanged: (route) => Navigator.of(context).pop((route: route)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<RouteContent?>(
|
||||||
|
value: null,
|
||||||
|
title: Text(l10n.recordNoRoute),
|
||||||
|
),
|
||||||
|
for (final stored in state.visible)
|
||||||
|
if (stored.content case final route? when route.planned != null)
|
||||||
|
RadioListTile<RouteContent?>(
|
||||||
|
value: route,
|
||||||
|
title: Text(route.name),
|
||||||
|
subtitle: Text(routeSummary(l10n, locale, route)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// [bloc] where its start ended: recording, refused location or nearby
|
/// [bloc] where its start ended: recording, refused location or nearby
|
||||||
/// devices, or without a rower.
|
/// devices, or without a rower.
|
||||||
Future<RecordingState> startedOrRefused(RecordingBloc bloc) =>
|
Future<RecordingState> startedOrRefused(RecordingBloc bloc) =>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue