feat(train): reminders to move and workouts suggested from the steps (ADR 0037)

Settings' Moving more, on Android: a reminder to get up after an idle
time, and a suggested walk or run, each off until turned on (then every
30 minutes, weekdays 9:00 to 18:00, not 12:00 to 13:00, each value the
person's), turning on asking for the activity and notification
permissions.

A WorkManager job every 15 minutes reads the phone's step counter and
keeps two hours of readings. It reminds where fewer than 50 steps came
over the idle time, all of it within the hours and outside the break,
on the person's days, never while Train records or the phone is in a
car (Android's car mode, or a car's Bluetooth audio or hands-free). It
suggests a walk or a run where the last span averaged a walking or
running cadence, once an hour; tapped, the Record sheet for it opens.
The decisions are pure Kotlin (Movement.kt), tested on the JVM; the
notifications' words are the app's, written into the job's
configuration as the person's app opens.

Polar's SDK sets neither the band's inactivity alert nor tells of one
without an all-day connection, hence the phone's steps (ADR 0037).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 14:32:02 +01:00
commit e4585b478d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 1882 additions and 30 deletions

File diff suppressed because one or more lines are too long

View file

@ -36,12 +36,16 @@ class MainActivity : FlutterActivity() {
/** The recording's notification tapped: the recording's screen (ADR 0030, D21). */
private val recordingOpenings = RecordingOpenings()
/** A suggested workout's notification tapped: the Record sheet for it (ADR 0037, D2). */
private val movementOpenings = MovementOpenings()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
health.onIntent(intent)
// Not again where Android makes the activity anew with the same intent.
if (savedInstanceState == null) files.onIntent(intent)
recordingOpenings.onIntent(intent)
movementOpenings.onIntent(intent)
}
override fun onNewIntent(intent: Intent) {
@ -49,6 +53,7 @@ class MainActivity : FlutterActivity() {
health.onIntent(intent)
files.onIntent(intent)
recordingOpenings.onIntent(intent)
movementOpenings.onIntent(intent)
}
@Deprecated("FlutterActivity has no result registry")
@ -125,6 +130,15 @@ class MainActivity : FlutterActivity() {
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
EventChannel(messenger, "app.halkyon.train/recorder/opened")
.setStreamHandler(recordingOpenings.opened)
// Reminders to move and suggested workouts (ADR 0037).
MethodChannel(messenger, "app.halkyon.train/movement").setMethodCallHandler { call, result ->
when (call.method) {
"available" -> result.success(MovementWorker.available(this))
"configure" -> result.success(MovementWorker.schedule(this, call.arguments<Map<String, Any?>>()).let { null })
else -> result.notImplemented()
}
}
EventChannel(messenger, "app.halkyon.train/movement/opened").setStreamHandler(movementOpenings.opened)
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music)
EventChannel(messenger, "app.halkyon.train/music/fetched").setStreamHandler(music.fetched)
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(

View file

@ -0,0 +1,133 @@
package app.halkyon.train
import java.time.Instant
import java.time.ZoneId
/**
* What the person chose for reminders to move and suggested workouts (ADR
* 0037, D3): the times are minutes of the day, the days ISO's (1 Monday to
* 7 Sunday).
*/
data class MovementSettings(
val remind: Boolean,
val idleMinutes: Int,
val from: Int,
val until: Int,
val breakFrom: Int,
val breakUntil: Int,
val days: Set<Int>,
val suggest: Boolean,
)
/** The step counter's count at a moment (it counts up from the phone's start). */
data class StepReading(val atMs: Long, val steps: Long)
/** What the job keeps between its runs: the last readings, and when it last reminded and suggested. */
data class MovementState(
val readings: List<StepReading> = emptyList(),
val remindedMs: Long? = null,
val suggestedMs: Long? = null,
)
/** A nudge to the person: get up, or record what they seem to be doing. */
sealed interface MovementNudge {
data object Remind : MovementNudge
/** [activity] as the app names it: `walking` or `running`. */
data class Suggest(val activity: String) : MovementNudge
}
/**
* Whether to remind or suggest, from the step counter's readings (ADR 0037,
* D1, D2): pure, so it is tested on the JVM.
*/
object Movement {
/** Fewer steps than this over the idle time is idle. */
const val IDLE_STEPS = 50L
/** Steps a minute, averaged over the last span, for a walk and for a run. */
const val WALKING = 80.0
const val RUNNING = 140.0
/** The span a suggestion averages over: about one run of the job. */
private const val SPAN_MIN_MS = 10 * 60_000L
private const val SPAN_MAX_MS = 25 * 60_000L
/** At most one suggestion an hour. */
const val SUGGEST_GAP_MS = 60 * 60_000L
/** How long readings are kept. */
const val KEEP_MS = 2 * 60 * 60_000L
/** The job's runs drift: a reading this much short of the idle time still counts. */
private const val SLACK_MS = 2 * 60_000L
/**
* [state] with [reading] added, those older than [KEEP_MS] let go, and all
* before it where the counter went back (the phone started again).
*/
fun withReading(state: MovementState, reading: StepReading): MovementState {
val restarted = state.readings.lastOrNull()?.let { it.steps > reading.steps } == true
val kept = if (restarted) emptyList() else state.readings.filter { reading.atMs - it.atMs in 0..KEEP_MS }
return state.copy(readings = kept + reading)
}
/**
* The nudge for now, the newest of [state]'s readings being now's; null for
* none. A suggestion goes before a reminder.
*/
fun decide(
state: MovementState,
settings: MovementSettings,
zone: ZoneId,
recording: Boolean,
inCar: Boolean,
): MovementNudge? {
val now = state.readings.lastOrNull() ?: return null
if (recording || inCar) return null
return suggestion(state, settings, now) ?: reminder(state, settings, zone, now)
}
/** [state] once [nudge] was given at [atMs]. */
fun after(state: MovementState, nudge: MovementNudge, atMs: Long): MovementState =
when (nudge) {
MovementNudge.Remind -> state.copy(remindedMs = atMs)
is MovementNudge.Suggest -> state.copy(suggestedMs = atMs)
}
private fun suggestion(state: MovementState, settings: MovementSettings, now: StepReading): MovementNudge? {
if (!settings.suggest) return null
if (state.suggestedMs?.let { now.atMs - it < SUGGEST_GAP_MS } == true) return null
val before =
state.readings.lastOrNull { now.atMs - it.atMs in SPAN_MIN_MS..SPAN_MAX_MS } ?: return null
val perMinute = (now.steps - before.steps) / ((now.atMs - before.atMs) / 60_000.0)
return when {
perMinute >= RUNNING -> MovementNudge.Suggest("running")
perMinute >= WALKING -> MovementNudge.Suggest("walking")
else -> null
}
}
private fun reminder(
state: MovementState,
settings: MovementSettings,
zone: ZoneId,
now: StepReading,
): MovementNudge? {
if (!settings.remind) return null
val idleMs = settings.idleMinutes * 60_000L
if (state.remindedMs?.let { now.atMs - it < idleMs } == true) return null
val since = state.readings.lastOrNull { it.atMs <= now.atMs - idleMs + SLACK_MS } ?: return null
if (now.steps - since.steps >= IDLE_STEPS) return null
// All of the idle time within the person's hours, on their day, and none of it in their break.
val start = Instant.ofEpochMilli(since.atMs).atZone(zone)
val end = Instant.ofEpochMilli(now.atMs).atZone(zone)
if (start.toLocalDate() != end.toLocalDate()) return null
if (end.dayOfWeek.value !in settings.days) return null
val startMinute = start.hour * 60 + start.minute
val endMinute = end.hour * 60 + end.minute
if (startMinute < settings.from || endMinute > settings.until) return null
if (startMinute < settings.breakUntil && endMinute > settings.breakFrom) return null
return MovementNudge.Remind
}
}

View file

@ -0,0 +1,37 @@
package app.halkyon.train
import android.content.Intent
import io.flutter.plugin.common.EventChannel
/**
* A suggested workout's notification tapped (ADR 0037, D2): the app opens the
* Record sheet for its activity. The tap is handed to the app as it listens
* (now, or once it does, after a cold start).
*/
class MovementOpenings {
private var sink: EventChannel.EventSink? = null
private var waiting: String? = null
val opened =
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
sink = events
waiting?.let {
waiting = null
events.success(it)
}
}
override fun onCancel(arguments: Any?) {
sink = null
}
}
/** Takes in [intent]'s suggested activity, if it has one. */
fun onIntent(intent: Intent?) {
val activity = intent?.getStringExtra(MovementWorker.EXTRA_SUGGESTED) ?: return
// Asked once: the activity made anew with the same intent asks no more.
intent.removeExtra(MovementWorker.EXTRA_SUGGESTED)
sink?.success(activity) ?: run { waiting = activity }
}
}

View file

@ -0,0 +1,279 @@
package app.halkyon.train
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.UiModeManager
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.io.File
import java.time.ZoneId
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
/**
* Every 15 minutes while reminders or suggestions are on (ADR 0037, D1,
* D2): reads the step counter, keeps the last readings, and reminds the
* person to move or suggests the workout they seem to be doing, as
* [Movement] decides, in the words the app wrote into its configuration (D4).
*/
class MovementWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
val context = applicationContext
val config = configOf(context) ?: return Result.success()
val steps = stepCount(context) ?: return Result.success()
val stateFile = File(context.filesDir, STATE)
val now = System.currentTimeMillis()
var state = Movement.withReading(stateOf(stateFile), StepReading(now, steps))
val nudge =
Movement.decide(
state,
config.settings,
ZoneId.systemDefault(),
recording = RecorderService.isRecording(),
inCar = inCar(context),
)
if (nudge != null && notify(context, config.texts, nudge)) {
state = Movement.after(state, nudge, now)
}
stateFile.writeText(jsonOf(state).toString())
return Result.success()
}
/** The person's choices and the app's words for the notifications. */
private class Config(val settings: MovementSettings, val texts: JSONObject)
companion object {
private const val CONFIG = "movement.json"
private const val STATE = "movement_state.json"
private const val WORK = "movement"
private const val CHANNEL = "movement"
private const val REMINDER_ID = 4101
private const val SUGGESTION_ID = 4102
/** The extra of a suggestion's intent: the activity to open the Record sheet for. */
const val EXTRA_SUGGESTED = "app.halkyon.train.SUGGESTED_ACTIVITY"
/** Whether the phone counts steps at all. */
fun available(context: Context): Boolean =
context.getSystemService(SensorManager::class.java)?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
/**
* Runs every 15 minutes with [config] (`settings`, `texts`), or no
* longer where it is null or turns both off.
*/
fun schedule(context: Context, config: Map<String, Any?>?) {
val file = File(context.filesDir, CONFIG)
val work = WorkManager.getInstance(context)
@Suppress("UNCHECKED_CAST")
val settings = config?.get("settings") as Map<String, Any?>?
if (settings == null || (settings["remind"] != true && settings["suggest"] != true)) {
file.delete()
File(context.filesDir, STATE).delete()
work.cancelUniqueWork(WORK)
return
}
file.writeText(JsonText.of(config))
work.enqueueUniquePeriodicWork(
WORK,
// A new config is read by the next run; the schedule stays.
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<MovementWorker>(15, TimeUnit.MINUTES).build(),
)
}
private fun configOf(context: Context): Config? {
val file = File(context.filesDir, CONFIG)
if (!file.exists()) return null
return runCatching {
val json = JSONObject(file.readText())
val settings = json.getJSONObject("settings")
val days = settings.getJSONArray("days")
Config(
MovementSettings(
remind = settings.getBoolean("remind"),
idleMinutes = settings.getInt("idleMinutes"),
from = settings.getInt("from"),
until = settings.getInt("until"),
breakFrom = settings.getInt("breakFrom"),
breakUntil = settings.getInt("breakUntil"),
days = (0 until days.length()).map(days::getInt).toSet(),
suggest = settings.getBoolean("suggest"),
),
json.getJSONObject("texts"),
)
}.getOrNull()
}
private fun stateOf(file: File): MovementState =
runCatching {
val json = JSONObject(file.readText())
val readings = json.getJSONArray("readings")
MovementState(
readings =
(0 until readings.length()).map {
val reading = readings.getJSONArray(it)
StepReading(reading.getLong(0), reading.getLong(1))
},
remindedMs = json.optLong("remindedMs").takeIf { json.has("remindedMs") },
suggestedMs = json.optLong("suggestedMs").takeIf { json.has("suggestedMs") },
)
}.getOrDefault(MovementState())
private fun jsonOf(state: MovementState): JSONObject =
JSONObject().apply {
put("readings", JSONArray(state.readings.map { JSONArray(listOf(it.atMs, it.steps)) }))
state.remindedMs?.let { put("remindedMs", it) }
state.suggestedMs?.let { put("suggestedMs", it) }
}
/** The step counter's count now; null without the sensor, the permission or an answer within 10 s. */
private suspend fun stepCount(context: Context): Long? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !granted(context, Manifest.permission.ACTIVITY_RECOGNITION)) {
return null
}
val manager = context.getSystemService(SensorManager::class.java) ?: return null
val sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) ?: return null
return withTimeoutOrNull(TimeUnit.SECONDS.toMillis(10)) {
suspendCancellableCoroutine { continuation ->
val listener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
manager.unregisterListener(this)
if (continuation.isActive) continuation.resume(event.values[0].toLong())
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
continuation.invokeOnCancellation { manager.unregisterListener(listener) }
}
}
}
/**
* Whether the phone is in a car (D1): Android's car mode, or a car's
* audio or hands-free connected over Bluetooth.
*/
private suspend fun inCar(context: Context): Boolean {
val ui = context.getSystemService(UiModeManager::class.java)
if (ui?.currentModeType == Configuration.UI_MODE_TYPE_CAR) return true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !granted(context, Manifest.permission.BLUETOOTH_CONNECT)) {
return false
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return false
if (!adapter.isEnabled) return false
for (profile in listOf(BluetoothProfile.A2DP, BluetoothProfile.HEADSET)) {
val devices = withTimeoutOrNull(TimeUnit.SECONDS.toMillis(3)) { connected(context, adapter, profile) }
if (devices.orEmpty().any(::isCar)) return true
}
return false
}
@SuppressLint("MissingPermission") // Checked by inCar.
private fun isCar(device: BluetoothDevice): Boolean =
when (device.bluetoothClass?.deviceClass) {
BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO,
BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE -> true
else -> false
}
@SuppressLint("MissingPermission") // Checked by inCar.
private suspend fun connected(context: Context, adapter: BluetoothAdapter, profile: Int): List<BluetoothDevice> =
suspendCancellableCoroutine { continuation ->
val asked =
adapter.getProfileProxy(
context,
object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val devices = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
adapter.closeProfileProxy(profile, proxy)
if (continuation.isActive) continuation.resume(devices)
}
override fun onServiceDisconnected(profile: Int) {}
},
profile,
)
if (!asked && continuation.isActive) continuation.resume(emptyList())
}
/** Shows [nudge]; whether it could (notifications allowed). */
@SuppressLint("MissingPermission") // areNotificationsEnabled is checked.
private fun notify(context: Context, texts: JSONObject, nudge: MovementNudge): Boolean {
val notifications = NotificationManagerCompat.from(context)
if (!notifications.areNotificationsEnabled()) return false
context
.getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(CHANNEL, texts.optString("channel", "Moving more"), NotificationManager.IMPORTANCE_DEFAULT),
)
val (id, title, text, activity) =
when (nudge) {
MovementNudge.Remind -> Quad(REMINDER_ID, texts.optString("remindTitle"), texts.optString("remindText"), null)
is MovementNudge.Suggest ->
Quad(
SUGGESTION_ID,
texts.optString("${nudge.activity}Title"),
texts.optString("suggestText"),
nudge.activity,
)
}
val open =
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { intent ->
if (activity != null) intent.putExtra(EXTRA_SUGGESTED, activity)
PendingIntent.getActivity(
context,
id,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
runCatching {
notifications.notify(
id,
NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_recording)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(open)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build(),
)
}.onFailure { Log.i("Movement", "Not shown: ${it.javaClass.simpleName}") }
return true
}
private fun granted(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
private data class Quad(val id: Int, val title: String, val text: String, val activity: String?)
}

View file

@ -0,0 +1,119 @@
package app.halkyon.train
import java.time.LocalDateTime
import java.time.ZoneId
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Reminders to move and workouts suggested from the step counter (ADR 0037). */
class MovementTest {
private val zone = ZoneId.of("Europe/Berlin")
private val settings =
MovementSettings(
remind = true,
idleMinutes = 30,
from = 9 * 60,
until = 18 * 60,
breakFrom = 12 * 60,
breakUntil = 13 * 60,
days = setOf(1, 2, 3, 4, 5),
suggest = true,
)
/** Monday 2 Nov 2026 at [hour]:[minute], Berlin's time. */
private fun at(hour: Int, minute: Int = 0, day: Int = 2): Long =
LocalDateTime.of(2026, 11, day, hour, minute).atZone(zone).toInstant().toEpochMilli()
/** The state after readings of [steps] at each of [times]. */
private fun read(vararg readings: Pair<Long, Long>, state: MovementState = MovementState()): MovementState =
readings.fold(state) { kept, (time, steps) -> Movement.withReading(kept, StepReading(time, steps)) }
private fun decide(
state: MovementState,
settings: MovementSettings = this.settings,
recording: Boolean = false,
inCar: Boolean = false,
) = Movement.decide(state, settings, zone, recording, inCar)
@Test
fun `still for the idle time within the hours, the person is reminded`() {
val state = read(at(10) to 5000, at(10, 15) to 5010, at(10, 30) to 5020)
assertEquals(MovementNudge.Remind, decide(state))
}
@Test
fun `a reading a little short of the idle time still counts, as the job drifts`() {
assertEquals(MovementNudge.Remind, decide(read(at(10, 1) to 5000, at(10, 30) to 5010)))
}
@Test
fun `steps over the idle time, or too little known yet, remind nobody`() {
assertNull(decide(read(at(10) to 5000, at(10, 30) to 5100)))
assertNull(decide(read(at(10, 15) to 5000, at(10, 30) to 5000)))
assertNull(decide(read(at(10, 30) to 5000)))
}
@Test
fun `reminded once each idle time, not at every run`() {
val first = read(at(10) to 5000, at(10, 30) to 5000)
val reminded = Movement.after(first, MovementNudge.Remind, at(10, 30))
assertNull(decide(read(at(10, 45) to 5000, state = reminded)))
assertEquals(MovementNudge.Remind, decide(read(at(10, 45) to 5000, at(11) to 5000, state = reminded)))
}
@Test
fun `outside the hours, over the break, on another day or switched off, nobody is reminded`() {
assertNull(decide(read(at(8, 30) to 0, at(9) to 0)), "idle since before nine")
assertEquals(MovementNudge.Remind, decide(read(at(9) to 0, at(9, 30) to 0)))
assertNull(decide(read(at(17, 45) to 0, at(18, 15) to 0)), "past six")
assertNull(decide(read(at(11, 45) to 0, at(12, 15) to 0)), "into the break")
assertNull(decide(read(at(12, 45) to 0, at(13, 15) to 0)), "out of the break")
assertEquals(MovementNudge.Remind, decide(read(at(13) to 0, at(13, 30) to 0)))
// Saturday 7 Nov.
assertNull(decide(read(at(10, day = 7) to 0, at(10, 30, day = 7) to 0)))
assertNull(decide(read(at(10) to 0, at(10, 30) to 0), settings.copy(remind = false)))
}
@Test
fun `while recording or in a car, nothing is said`() {
val idle = read(at(10) to 0, at(10, 30) to 0)
assertNull(decide(idle, recording = true))
assertNull(decide(idle, inCar = true))
val walking = read(at(10) to 0, at(10, 15) to 1500)
assertNull(decide(walking, recording = true))
}
@Test
fun `a walking or running cadence over the last span suggests the workout, once an hour`() {
val walking = read(at(10) to 0, at(10, 15) to 1500)
assertEquals(MovementNudge.Suggest("walking"), decide(walking))
val running = read(at(10) to 0, at(10, 15) to 2400)
assertEquals(MovementNudge.Suggest("running"), decide(running))
assertNull(decide(read(at(10) to 0, at(10, 15) to 900)), "60 a minute: pottering about")
assertNull(decide(walking, settings.copy(suggest = false)))
val suggested = Movement.after(walking, MovementNudge.Suggest("walking"), at(10, 15))
assertNull(decide(read(at(10, 30) to 3000, state = suggested)))
assertEquals(
MovementNudge.Suggest("walking"),
decide(read(at(10, 30) to 3000, at(11, 15) to 4500, at(11, 30) to 6000, state = suggested)),
)
}
@Test
fun `a span too short or too long suggests nothing`() {
assertNull(decide(read(at(10) to 0, at(10, 5) to 600)))
assertNull(decide(read(at(10) to 0, at(10, 40) to 4000)))
}
@Test
fun `readings older than two hours, and those before the phone started again, are let go`() {
val kept = read(at(8) to 100, at(9) to 200, at(10, 1) to 300)
assertEquals(listOf(at(9), at(10, 1)), kept.readings.map { it.atMs })
val restarted = read(at(10) to 5000, at(10, 15) to 20)
assertEquals(listOf(StepReading(at(10, 15), 20)), restarted.readings)
}
private fun assertNull(actual: Any?, message: String) = org.junit.Assert.assertNull(message, actual)
}

View file

@ -720,5 +720,26 @@
"weatherDirection": "{direction, select, n{Nord} ne{Nordost} e{Ost} se{Südost} s{Süd} sw{Südwest} w{West} nw{Nordwest} other{Wind}}",
"weatherPrecipitation": "{amount} mm Niederschlag",
"weatherDry": "Trocken",
"weatherBy": "Wetterdaten von Open-Meteo.com"
"weatherBy": "Wetterdaten von Open-Meteo.com",
"movementTitle": "Mehr bewegen",
"movementHint": "Erinnerungen aufzustehen und Trainings, wenn du dich bewegst",
"movementRemind": "Ans Bewegen erinnern",
"movementRemindHint": "Wenn das Handy eine Weile kaum einen Schritt gezählt hat; nie beim Aufzeichnen oder im Auto.",
"movementIdle": "Nach",
"movementIdleMinutes": "{minutes} Min. ohne Bewegung",
"movementHours": "Uhrzeit",
"movementBreak": "Pause",
"movementBreakHint": "Darin keine Erinnerungen.",
"movementDays": "Tage",
"movementSuggest": "Trainings vorschlagen",
"movementSuggestHint": "Wenn deine Schritte nach Gehen oder Laufen aussehen und nichts aufzeichnet: tippen, um den Rest aufzuzeichnen. Die eigene Erkennung deines Loop ist auf seiner Seite.",
"movementUnavailable": "Dieses Handy zählt keine Schritte, kann also weder erinnern noch vorschlagen.",
"movementRefused": "Train braucht dafür die Berechtigungen für körperliche Aktivität und Benachrichtigungen.",
"movementLate": "Die Schritte werden alle 15 Minuten gezählt, eine Erinnerung kann also etwas später kommen.",
"movementChannel": "Mehr bewegen",
"movementRemindTitle": "Zeit für Bewegung",
"movementRemindText": "{minutes} Minuten kaum ein Schritt. Lust auf einen kurzen Spaziergang?",
"movementWalkingTitle": "Unterwegs zu Fuß?",
"movementRunningTitle": "Beim Laufen?",
"movementSuggestText": "Tippen, um den Rest aufzuzeichnen."
}

View file

@ -912,5 +912,28 @@
"weatherPrecipitation": "{amount} mm fell",
"@weatherPrecipitation": {"placeholders": {"amount": {"type": "String"}}},
"weatherDry": "Nothing fell",
"weatherBy": "Weather data by Open-Meteo.com"
"weatherBy": "Weather data by Open-Meteo.com",
"movementTitle": "Moving more",
"movementHint": "Reminders to get up, and workouts suggested as you move",
"movementRemind": "Remind me to move",
"movementRemindHint": "When the phone counted hardly a step for a while; never while recording or in a car.",
"movementIdle": "After",
"movementIdleMinutes": "{minutes} min still",
"@movementIdleMinutes": {"placeholders": {"minutes": {"type": "int"}}},
"movementHours": "Hours",
"movementBreak": "Break",
"movementBreakHint": "No reminders over it.",
"movementDays": "Days",
"movementSuggest": "Suggest workouts",
"movementSuggestHint": "When your steps look like a walk or a run and nothing records: tap to record the rest of it. Your Loop's own detection is on its screen.",
"movementUnavailable": "This phone doesn't count steps, so it can neither remind nor suggest.",
"movementRefused": "Train needs the physical activity and notification permissions for this.",
"movementLate": "The steps are counted every 15 minutes, so a reminder can come a little late.",
"movementChannel": "Moving more",
"movementRemindTitle": "Time to move",
"movementRemindText": "{minutes} minutes with hardly a step. Up for a short walk?",
"@movementRemindText": {"placeholders": {"minutes": {"type": "int"}}},
"movementWalkingTitle": "Out for a walk?",
"movementRunningTitle": "Out for a run?",
"movementSuggestText": "Tap to record the rest of it."
}

View file

@ -4335,6 +4335,132 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Weather data by Open-Meteo.com'**
String get weatherBy;
/// No description provided for @movementTitle.
///
/// In en, this message translates to:
/// **'Moving more'**
String get movementTitle;
/// No description provided for @movementHint.
///
/// In en, this message translates to:
/// **'Reminders to get up, and workouts suggested as you move'**
String get movementHint;
/// No description provided for @movementRemind.
///
/// In en, this message translates to:
/// **'Remind me to move'**
String get movementRemind;
/// No description provided for @movementRemindHint.
///
/// In en, this message translates to:
/// **'When the phone counted hardly a step for a while; never while recording or in a car.'**
String get movementRemindHint;
/// No description provided for @movementIdle.
///
/// In en, this message translates to:
/// **'After'**
String get movementIdle;
/// No description provided for @movementIdleMinutes.
///
/// In en, this message translates to:
/// **'{minutes} min still'**
String movementIdleMinutes(int minutes);
/// No description provided for @movementHours.
///
/// In en, this message translates to:
/// **'Hours'**
String get movementHours;
/// No description provided for @movementBreak.
///
/// In en, this message translates to:
/// **'Break'**
String get movementBreak;
/// No description provided for @movementBreakHint.
///
/// In en, this message translates to:
/// **'No reminders over it.'**
String get movementBreakHint;
/// No description provided for @movementDays.
///
/// In en, this message translates to:
/// **'Days'**
String get movementDays;
/// No description provided for @movementSuggest.
///
/// In en, this message translates to:
/// **'Suggest workouts'**
String get movementSuggest;
/// No description provided for @movementSuggestHint.
///
/// In en, this message translates to:
/// **'When your steps look like a walk or a run and nothing records: tap to record the rest of it. Your Loop\'s own detection is on its screen.'**
String get movementSuggestHint;
/// No description provided for @movementUnavailable.
///
/// In en, this message translates to:
/// **'This phone doesn\'t count steps, so it can neither remind nor suggest.'**
String get movementUnavailable;
/// No description provided for @movementRefused.
///
/// In en, this message translates to:
/// **'Train needs the physical activity and notification permissions for this.'**
String get movementRefused;
/// No description provided for @movementLate.
///
/// In en, this message translates to:
/// **'The steps are counted every 15 minutes, so a reminder can come a little late.'**
String get movementLate;
/// No description provided for @movementChannel.
///
/// In en, this message translates to:
/// **'Moving more'**
String get movementChannel;
/// No description provided for @movementRemindTitle.
///
/// In en, this message translates to:
/// **'Time to move'**
String get movementRemindTitle;
/// No description provided for @movementRemindText.
///
/// In en, this message translates to:
/// **'{minutes} minutes with hardly a step. Up for a short walk?'**
String movementRemindText(int minutes);
/// No description provided for @movementWalkingTitle.
///
/// In en, this message translates to:
/// **'Out for a walk?'**
String get movementWalkingTitle;
/// No description provided for @movementRunningTitle.
///
/// In en, this message translates to:
/// **'Out for a run?'**
String get movementRunningTitle;
/// No description provided for @movementSuggestText.
///
/// In en, this message translates to:
/// **'Tap to record the rest of it.'**
String get movementSuggestText;
}
class _AppLocalizationsDelegate

View file

@ -2823,4 +2823,77 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get weatherBy => 'Wetterdaten von Open-Meteo.com';
@override
String get movementTitle => 'Mehr bewegen';
@override
String get movementHint =>
'Erinnerungen aufzustehen und Trainings, wenn du dich bewegst';
@override
String get movementRemind => 'Ans Bewegen erinnern';
@override
String get movementRemindHint =>
'Wenn das Handy eine Weile kaum einen Schritt gezählt hat; nie beim Aufzeichnen oder im Auto.';
@override
String get movementIdle => 'Nach';
@override
String movementIdleMinutes(int minutes) {
return '$minutes Min. ohne Bewegung';
}
@override
String get movementHours => 'Uhrzeit';
@override
String get movementBreak => 'Pause';
@override
String get movementBreakHint => 'Darin keine Erinnerungen.';
@override
String get movementDays => 'Tage';
@override
String get movementSuggest => 'Trainings vorschlagen';
@override
String get movementSuggestHint =>
'Wenn deine Schritte nach Gehen oder Laufen aussehen und nichts aufzeichnet: tippen, um den Rest aufzuzeichnen. Die eigene Erkennung deines Loop ist auf seiner Seite.';
@override
String get movementUnavailable =>
'Dieses Handy zählt keine Schritte, kann also weder erinnern noch vorschlagen.';
@override
String get movementRefused =>
'Train braucht dafür die Berechtigungen für körperliche Aktivität und Benachrichtigungen.';
@override
String get movementLate =>
'Die Schritte werden alle 15 Minuten gezählt, eine Erinnerung kann also etwas später kommen.';
@override
String get movementChannel => 'Mehr bewegen';
@override
String get movementRemindTitle => 'Zeit für Bewegung';
@override
String movementRemindText(int minutes) {
return '$minutes Minuten kaum ein Schritt. Lust auf einen kurzen Spaziergang?';
}
@override
String get movementWalkingTitle => 'Unterwegs zu Fuß?';
@override
String get movementRunningTitle => 'Beim Laufen?';
@override
String get movementSuggestText => 'Tippen, um den Rest aufzuzeichnen.';
}

View file

@ -2810,6 +2810,79 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get weatherBy => 'Weather data by Open-Meteo.com';
@override
String get movementTitle => 'Moving more';
@override
String get movementHint =>
'Reminders to get up, and workouts suggested as you move';
@override
String get movementRemind => 'Remind me to move';
@override
String get movementRemindHint =>
'When the phone counted hardly a step for a while; never while recording or in a car.';
@override
String get movementIdle => 'After';
@override
String movementIdleMinutes(int minutes) {
return '$minutes min still';
}
@override
String get movementHours => 'Hours';
@override
String get movementBreak => 'Break';
@override
String get movementBreakHint => 'No reminders over it.';
@override
String get movementDays => 'Days';
@override
String get movementSuggest => 'Suggest workouts';
@override
String get movementSuggestHint =>
'When your steps look like a walk or a run and nothing records: tap to record the rest of it. Your Loop\'s own detection is on its screen.';
@override
String get movementUnavailable =>
'This phone doesn\'t count steps, so it can neither remind nor suggest.';
@override
String get movementRefused =>
'Train needs the physical activity and notification permissions for this.';
@override
String get movementLate =>
'The steps are counted every 15 minutes, so a reminder can come a little late.';
@override
String get movementChannel => 'Moving more';
@override
String get movementRemindTitle => 'Time to move';
@override
String movementRemindText(int minutes) {
return '$minutes minutes with hardly a step. Up for a short walk?';
}
@override
String get movementWalkingTitle => 'Out for a walk?';
@override
String get movementRunningTitle => 'Out for a run?';
@override
String get movementSuggestText => 'Tap to record the rest of it.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -23,6 +23,8 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/movement/data/movement_platform.dart';
import 'package:halkyon_train_flutter/src/feature/movement/data/moving_more.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_platform.dart';
@ -145,6 +147,11 @@ class TrainPerson(
/// What this phone keeps of their marked playlists (D47); null where
/// [music] is.
final MusicDownloads? keptMusic,
/// Their reminders to move and suggested workouts on this phone (ADR
/// 0037); null where nothing counts steps for them (iOS for now, the
/// desktop).
final MovingMore? movement,
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Workouts deleted, to be undone for a moment (D24); dropped with the
@ -529,6 +536,13 @@ Future<TrainPerson> _openPersonData(
loop: loop,
music: music,
keptMusic: keptMusic,
movement: recorder == null
? null
: MovingMore(
AndroidMovementPlatform(),
recorder,
File(p.join(folder.path, 'movement.json')),
),
onDispose: () async {
resumed?.dispose();
try {

View file

@ -0,0 +1,51 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/movement/data/moving_more.dart';
import 'package:halkyon_train_flutter/src/feature/movement/model/movement_settings.dart';
/// The Moving more screen (ADR 0037, D3): the person's settings, whether the
/// phone counts steps, and whether turning on was refused.
class MovementCubit(final MovingMore _movement) extends Cubit<MovementState> {
this : super(MovementState(settings: _movement.read()));
/// Asks the phone whether it counts steps.
Future<void> open() async {
final available = await _movement.platform.available();
if (!isClosed) {
emit(state._with(available: available));
}
}
/// Changes the settings to [settings], the job's words [texts].
Future<void> change(MovementSettings settings, MovementTexts texts) async {
final turningOn = settings.on && !state.settings.on;
emit(state._with(settings: settings, refused: false));
final kept = await _movement.write(settings, texts);
if (!isClosed) {
emit(state._with(settings: kept, refused: turningOn && !kept.on));
}
}
}
final class const MovementState({
required final MovementSettings settings,
/// Whether the phone counts steps; null until asked.
final bool? available,
/// Whether the permissions were refused as it was turned on.
final bool refused = false,
}) with Equatable {
MovementState _with({
MovementSettings? settings,
bool? available,
bool? refused,
}) => MovementState(
settings: settings ?? this.settings,
available: available ?? this.available,
refused: refused ?? this.refused,
);
@override
List<Object?> get props => [settings, available, refused];
}

View file

@ -0,0 +1,37 @@
import 'package:flutter/services.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The phone's side of reminders to move and suggested workouts (ADR 0037):
/// whether it counts steps, the job configured or stopped, and the
/// suggestions tapped.
abstract interface class MovementPlatform() {
/// Whether the phone has a step counter.
Future<bool> available();
/// Runs the job with [config] (`settings`, `texts`), or stops it where it
/// is null or turns both off.
Future<void> configure(Map<String, Object?>? config);
/// The activity of each suggestion tapped, one that opened the app
/// included.
Stream<Activity> openings();
}
/// [MovementPlatform] on Android's `MovementWorker`.
class AndroidMovementPlatform() implements MovementPlatform {
static const _channel = MethodChannel('app.halkyon.train/movement');
static const _opened = EventChannel('app.halkyon.train/movement/opened');
@override
Future<bool> available() async =>
await _channel.invokeMethod<bool>('available') ?? false;
@override
Future<void> configure(Map<String, Object?>? config) =>
_channel.invokeMethod<void>('configure', config);
@override
Stream<Activity> openings() => _opened.receiveBroadcastStream().expand(
(name) => [?Activity.values.asNameMap()[name]],
);
}

View file

@ -0,0 +1,74 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:halkyon_train_flutter/src/feature/movement/data/movement_platform.dart';
import 'package:halkyon_train_flutter/src/feature/movement/model/movement_settings.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
/// A person's reminders to move and suggested workouts on this phone (ADR
/// 0037): their settings in `movement.json` (never synced; without a file,
/// in memory), the job configured with them, and the permissions the job
/// needs asked for as either is turned on.
class MovingMore(
/// The phone's side.
final MovementPlatform platform,
final RecorderPlatform _recorder, [
final File? _file,
]) {
MovementSettings? _memory;
/// The person's settings; the defaults where none were kept.
MovementSettings read() {
final file = _file;
if (file == null) {
return _memory ?? const MovementSettings();
}
try {
return file.existsSync()
? MovementSettings.of(jsonDecode(file.readAsStringSync()))
: const MovementSettings();
} on Object catch (error) {
log('Movement settings not read: ${error.runtimeType}', name: 'Movement');
return const MovementSettings();
}
}
/// Keeps [settings] and configures the job with them and [texts]; turning
/// either on asks for the activity and notification permissions first,
/// and keeps both off where they are refused. The settings kept.
Future<MovementSettings> write(
MovementSettings settings,
MovementTexts texts,
) async {
var kept = settings;
if (settings.on && !read().on) {
const wanted = {
RecorderPermission.activity,
RecorderPermission.notifications,
};
final granted = await _recorder.ask(wanted);
if (!granted.containsAll(wanted)) {
kept = settings.copyWith(remind: false, suggest: false);
}
}
final file = _file;
if (file == null) {
_memory = kept;
} else {
await file.writeAsString(jsonEncode(kept.toJson()));
}
await apply(texts, kept);
return kept;
}
/// Configures the job with the kept settings (or [settings]) and [texts]:
/// as the person's app opens, so the job follows whoever was last open
/// and speaks their language.
Future<void> apply(MovementTexts texts, [MovementSettings? settings]) {
final kept = settings ?? read();
return platform.configure(
kept.on ? {'settings': kept.toJson(), 'texts': texts.toJson()} : null,
);
}
}

View file

@ -0,0 +1,117 @@
import 'package:equatable/equatable.dart';
/// What the person chose for reminders to move and suggested workouts (ADR
/// 0037, D3): both off until turned on; reminded after [idleMinutes] still,
/// between [from] and [until] (minutes of the day), not between [breakFrom]
/// and [breakUntil], on [days] (1 Monday to 7 Sunday).
final class const MovementSettings({
final bool remind = false,
final int idleMinutes = 30,
final int from = 9 * 60,
final int until = 18 * 60,
final int breakFrom = 12 * 60,
final int breakUntil = 13 * 60,
final Set<int> days = const {1, 2, 3, 4, 5},
final bool suggest = false,
}) with Equatable {
/// The settings [json] holds; the defaults where it holds none.
factory of(Object? json) {
if (json is! Map<String, Object?>) {
return const MovementSettings();
}
const fallback = MovementSettings();
int minutes(String key, int otherwise) => switch (json[key]) {
final int value when value >= 0 && value <= 24 * 60 => value,
_ => otherwise,
};
return MovementSettings(
remind: json['remind'] == true,
idleMinutes: switch (json['idleMinutes']) {
final int value when idleChoices.contains(value) => value,
_ => fallback.idleMinutes,
},
from: minutes('from', fallback.from),
until: minutes('until', fallback.until),
breakFrom: minutes('breakFrom', fallback.breakFrom),
breakUntil: minutes('breakUntil', fallback.breakUntil),
days: switch (json['days']) {
final List<Object?> days => {
for (final day in days)
if (day case final int day when day >= 1 && day <= 7) day,
},
_ => fallback.days,
},
suggest: json['suggest'] == true,
);
}
/// The idle times offered, in minutes.
static const idleChoices = [15, 20, 30, 45, 60, 90, 120];
/// Whether the job runs at all.
bool get on => remind || suggest;
MovementSettings copyWith({
bool? remind,
int? idleMinutes,
int? from,
int? until,
int? breakFrom,
int? breakUntil,
Set<int>? days,
bool? suggest,
}) => MovementSettings(
remind: remind ?? this.remind,
idleMinutes: idleMinutes ?? this.idleMinutes,
from: from ?? this.from,
until: until ?? this.until,
breakFrom: breakFrom ?? this.breakFrom,
breakUntil: breakUntil ?? this.breakUntil,
days: days ?? this.days,
suggest: suggest ?? this.suggest,
);
/// As the job and the person's `movement.json` hold it.
Map<String, Object?> toJson() => {
'remind': remind,
'idleMinutes': idleMinutes,
'from': from,
'until': until,
'breakFrom': breakFrom,
'breakUntil': breakUntil,
'days': [...days.toList()..sort()],
'suggest': suggest,
};
@override
List<Object?> get props => [
remind,
idleMinutes,
from,
until,
breakFrom,
breakUntil,
[...days.toList()..sort()],
suggest,
];
}
/// The notifications' words, in the person's language (D4): written into
/// the job's configuration, as it runs while the app is closed.
final class const MovementTexts({
required final String channel,
required final String remindTitle,
required final String remindText,
required final String walkingTitle,
required final String runningTitle,
required final String suggestText,
}) {
Map<String, String> toJson() => {
'channel': channel,
'remindTitle': remindTitle,
'remindText': remindText,
'walkingTitle': walkingTitle,
'runningTitle': runningTitle,
'suggestText': suggestText,
};
}

View file

@ -0,0 +1,241 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/movement/bloc/movement_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/movement/model/movement_settings.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// The notifications' words for [settings], in [l10n]'s language (D4).
MovementTexts movementTexts(
AppLocalizations l10n,
MovementSettings settings,
) => MovementTexts(
channel: l10n.movementChannel,
remindTitle: l10n.movementRemindTitle,
remindText: l10n.movementRemindText(settings.idleMinutes),
walkingTitle: l10n.movementWalkingTitle,
runningTitle: l10n.movementRunningTitle,
suggestText: l10n.movementSuggestText,
);
/// Opens Moving more, from Settings (ADR 0037, D3).
Future<void> openMovementScreen(BuildContext context) {
final movement = PersonScope.of<TrainPerson>(context).movement!;
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider(
create: (context) => MovementCubit(movement)..open(),
child: const MovementScreen(),
),
),
);
}
/// Reminders to move and suggested workouts (ADR 0037): each on its own
/// switch, the reminder's idle time, hours, break and days.
class const MovementScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final locale = Localizations.localeOf(context).toString();
final state = context.watch<MovementCubit>().state;
final settings = state.settings;
final usable = state.available ?? false;
void change(MovementSettings changed) => unawaited(
context.read<MovementCubit>().change(
changed,
movementTexts(l10n, changed),
),
);
String time(int minutes) => DateFormat.Hm(
locale,
).format(DateTime(2000, 1, 1, minutes ~/ 60, minutes % 60));
Future<int?> pick(int minutes) async {
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: minutes ~/ 60, minute: minutes % 60),
);
return picked == null ? null : picked.hour * 60 + picked.minute;
}
final muted = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
// Monday 2 Nov 2026 onwards, for the days' short names.
String day(int weekday) =>
DateFormat.E(locale).format(DateTime(2026, 11, 1 + weekday));
return Scaffold(
appBar: AppBar(title: Text(l10n.movementTitle)),
body: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
child: ListView(
padding: const EdgeInsets.all(12),
children: [
if (state.available == false)
Padding(
padding: const EdgeInsets.all(8),
child: Text(l10n.movementUnavailable),
),
if (state.refused)
Padding(
padding: const EdgeInsets.all(8),
child: Text(
l10n.movementRefused,
style: TextStyle(color: theme.colorScheme.error),
),
),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile(
title: Text(l10n.movementRemind),
subtitle: Text(l10n.movementRemindHint),
value: settings.remind,
onChanged: usable
? (on) => change(settings.copyWith(remind: on))
: null,
),
if (settings.remind) ...[
ListTile(
title: Text(l10n.movementIdle),
trailing: DropdownButton<int>(
value: settings.idleMinutes,
items: [
for (final minutes in MovementSettings.idleChoices)
DropdownMenuItem(
value: minutes,
child: Text(l10n.movementIdleMinutes(minutes)),
),
],
onChanged: (minutes) => change(
settings.copyWith(idleMinutes: minutes),
),
),
),
_Range(
title: l10n.movementHours,
from: time(settings.from),
until: time(settings.until),
onFrom: () async {
if (await pick(settings.from) case final from?) {
change(settings.copyWith(from: from));
}
},
onUntil: () async {
if (await pick(settings.until) case final until?) {
change(settings.copyWith(until: until));
}
},
),
_Range(
title: l10n.movementBreak,
subtitle: l10n.movementBreakHint,
from: time(settings.breakFrom),
until: time(settings.breakUntil),
onFrom: () async {
if (await pick(settings.breakFrom) case final from?) {
change(settings.copyWith(breakFrom: from));
}
},
onUntil: () async {
if (await pick(settings.breakUntil)
case final until?) {
change(settings.copyWith(breakUntil: until));
}
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.movementDays,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var weekday = 1; weekday <= 7; weekday++)
FilterChip(
label: Text(day(weekday)),
selected: settings.days.contains(weekday),
onSelected: (on) => change(
settings.copyWith(
days: on
? {...settings.days, weekday}
: ({...settings.days}
..remove(weekday)),
),
),
),
],
),
],
),
),
],
],
),
),
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SwitchListTile(
title: Text(l10n.movementSuggest),
subtitle: Text(l10n.movementSuggestHint),
value: settings.suggest,
onChanged: usable
? (on) => change(settings.copyWith(suggest: on))
: null,
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Text(l10n.movementLate, style: muted),
),
],
),
),
),
);
}
}
/// A span of the day: its start and its end, each tapped to change.
class const _Range({
required final String title,
final String? subtitle,
required final String from,
required final String until,
required final VoidCallback onFrom,
required final VoidCallback onUntil,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(onPressed: onFrom, child: Text(from)),
const Text('–'),
TextButton(onPressed: onUntil, child: Text(until)),
],
),
);
}
}

View file

@ -7,6 +7,7 @@ import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/widget/equipment_screen.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/loop/widget/loop_screen.dart';
import 'package:halkyon_train_flutter/src/feature/movement/widget/movement_screen.dart';
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -87,6 +88,15 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
subtitle: Text(l10n.equipmentHint),
onTap: () => openEquipmentScreen(context),
),
// Reminders to move and suggested workouts (ADR
// 0037), where the phone counts steps for them.
if (train.movement != null)
ListTile(
leading: const Icon(LucideIcons.footprints),
title: Text(l10n.movementTitle),
subtitle: Text(l10n.movementHint),
onTap: () => openMovementScreen(context),
),
// Health Connect on this phone (D31).
if (train.health != null)
ListTile(

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plans_list.dart';
@ -88,37 +90,39 @@ class const ActivityCard({
leading: Icon(activityIcon(activity)),
title: Text(activityName(l10n, activity)),
trailing: FilledButton.tonal(
onPressed: startable ? () => _record(context) : null,
onPressed: startable
? () => unawaited(recordActivity(context, activity))
: null,
child: Text(l10n.recordStart),
),
),
);
}
}
/// The Record sheet for [activity]; why it couldn't start, where it
/// couldn't. Started, Train's frame opens the recording's screen
/// ([TrainShell]), whatever became of this card meanwhile.
Future<void> _record(BuildContext context) async {
final bloc = context.read<RecordingBloc>();
final started = startedOrRefused(bloc);
if (!await showRecordSheet(context, bloc, activity) || !context.mounted) {
return;
}
final state = await started;
if (!context.mounted) {
return;
}
final l10n = AppLocalizations.of(context);
final refused = switch (state) {
RecordingState(locationRefused: true) => l10n.recordLocationRefused,
RecordingState(bluetoothRefused: true) => l10n.recordRowerRefused,
RecordingState(rowerMissing: true) => l10n.recordNoRower,
_ => null,
};
if (refused != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(refused)),
);
}
/// The Record sheet for [activity]; why it couldn't start, where it
/// couldn't. Started, Train's frame opens the recording's screen
/// ([TrainShell]), whatever became of this card meanwhile.
Future<void> recordActivity(BuildContext context, Activity activity) async {
final bloc = context.read<RecordingBloc>();
final started = startedOrRefused(bloc);
if (!await showRecordSheet(context, bloc, activity) || !context.mounted) {
return;
}
final state = await started;
if (!context.mounted) {
return;
}
final l10n = AppLocalizations.of(context);
final refused = switch (state) {
RecordingState(locationRefused: true) => l10n.recordLocationRefused,
RecordingState(bluetoothRefused: true) => l10n.recordRowerRefused,
RecordingState(rowerMissing: true) => l10n.recordNoRower,
_ => null,
};
if (refused != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(refused)),
);
}
}

View file

@ -7,11 +7,14 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/movement/widget/movement_screen.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_bar.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_screen.dart';
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/tabs/record_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -108,7 +111,7 @@ class const TrainShell({
listener: (context, _) => unawaited(openRecordingScreen(context)),
),
],
child: _RecordingOpenings(child: listening),
child: _RecordingOpenings(child: _MovementOpenings(child: listening)),
);
}
}
@ -150,6 +153,53 @@ class _RecordingOpeningsState() extends State<_RecordingOpenings> {
Widget build(BuildContext context) => widget.child;
}
/// Reminders to move and suggested workouts (ADR 0037): the job configured
/// as the person's app opens, in their language (D3, D4), and a suggestion
/// tapped opening the Record sheet for its activity while nothing records.
class const _MovementOpenings({required final Widget child})
extends StatefulWidget {
@override
State<_MovementOpenings> createState() => _MovementOpeningsState();
}
class _MovementOpeningsState() extends State<_MovementOpenings> {
StreamSubscription<Activity>? _openings;
Locale? _configuredIn;
@override
void initState() {
super.initState();
final movement = PersonScope.of<TrainPerson>(context).movement;
_openings = movement?.platform.openings().listen((activity) {
if (mounted &&
context.read<RecordingBloc>().state.phase == RecordingPhase.idle) {
unawaited(recordActivity(context, activity));
}
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final locale = Localizations.localeOf(context);
final movement = PersonScope.of<TrainPerson>(context).movement;
if (movement != null && locale != _configuredIn) {
_configuredIn = locale;
final l10n = AppLocalizations.of(context);
unawaited(movement.apply(movementTexts(l10n, movement.read())));
}
}
@override
void dispose() {
unawaited(_openings?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
/// Offers [recording], found cut off, to be saved up to its last sample or
/// discarded, which asks first (D18).
Future<void> _offerCutOff(BuildContext context, Recording recording) async {

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